Copies text to the clipboard, swaps its icon to a check, and announces it to screen readers.
pnpm dlx shadcn@latest add buttonpnpm dlx shadcn@latest add @latheui/copy-buttonFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, XIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { spring, useMotionSafe } from "@/lib/motion"
import { Button, type ButtonProps } from "@/components/ui/button"
type Status = "idle" | "copied" | "error"
export type CopyButtonProps = Omit<ButtonProps, "value" | "asChild" | "loading"> & {
/** Text written to the clipboard. */
value: string
/** Milliseconds before the button returns to idle. */
timeout?: number
/** Accessible name, and the visible text when `children` is set. */
label?: string
/** Text the visible label changes to after copying, for example "Copied". Needs `children`. */
copiedLabel?: React.ReactNode
onCopy?: (value: string) => void
onCopyError?: (error: unknown) => void
}
const icons = { idle: CopyIcon, copied: CheckIcon, error: XIcon }
const messages = { idle: "", copied: "Copied", error: "Copy failed" }
function CopyButton({
value,
timeout = 1600,
label = "Copy",
copiedLabel,
onCopy,
onCopyError,
variant = "ghost",
size,
className,
children,
onClick,
...props
}: CopyButtonProps) {
const [status, setStatus] = React.useState<Status>("idle")
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
const { reduced, safe } = useMotionSafe()
React.useEffect(() => () => clearTimeout(timer.current), [])
async function copy(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event)
if (event.defaultPrevented) return
try {
await navigator.clipboard.writeText(value)
setStatus("copied")
onCopy?.(value)
} catch (error) {
setStatus("error")
onCopyError?.(error)
}
clearTimeout(timer.current)
timer.current = setTimeout(() => setStatus("idle"), timeout)
}
const Icon = icons[status]
return (
<Button
data-slot="copy-button"
data-status={status}
variant={variant}
size={size ?? (children ? "md" : "icon")}
aria-label={children ? undefined : label}
className={cn(status === "error" && "text-destructive", className)}
onClick={copy}
{...props}
>
<span className="relative grid size-4 place-items-center">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={status}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={safe(spring.snappy)}
className="absolute inset-0 grid place-items-center"
>
<Icon aria-hidden="true" />
</motion.span>
</AnimatePresence>
</span>
{children && copiedLabel !== undefined ? (
// Both labels share one cell, so the button keeps the width of the longer one.
<span className="inline-grid overflow-hidden text-left">
<motion.span
initial={false}
animate={{ y: status === "copied" && !reduced ? "-100%" : "0%", opacity: status === "copied" ? 0 : 1 }}
transition={safe(spring.snappy)}
className="col-start-1 row-start-1"
>
{children}
</motion.span>
<motion.span
aria-hidden="true"
initial={false}
animate={{ y: status === "copied" || reduced ? "0%" : "100%", opacity: status === "copied" ? 1 : 0 }}
transition={safe(spring.snappy)}
className="col-start-1 row-start-1"
>
{copiedLabel}
</motion.span>
</span>
) : (
children
)}
<span role="status" className="sr-only">
{messages[status]}
</span>
</Button>
)
}
export { CopyButton }Depends on
Also accepts Button props such as variant, size and className.
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | string | — | The text written to the clipboard. |
| label | string | "Copy" | Accessible name when the button has no children. |
| copiedLabel | ReactNode | — | Visible text after copying, for example “Copied”. Used with children. |
| timeout | number | 1600 | Milliseconds before the icon returns to idle. |
| onCopy | (value: string) => void | — | Called after a successful copy. |
| onCopyError | (error: unknown) => void | — | Called when the clipboard write is rejected. |
| variant | ButtonProps["variant"] | "ghost" | Passed to Button. |
| size | ButtonProps["size"] | "icon", or "md" with children | Passed to Button. |
The clipboard API needs a secure context. Handle the error if copying matters to the flow.
<CopyButton
value={token}
onCopyError={() => toast.error("Could not copy. Select the text and copy it manually.")}
/>