Chanhdai
componentcopy-buttonsource: ok · refreshed
Copy Button
Copy text to clipboard with visual, haptic, and audio feedback.
npx shadcn add "https://chanhdai.com/r/copy-button.json"free
Where it goes
kanban
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
iconslucide-react
Registry dependencies
buttonhttps://chanhdai.com/r/icon-swap.json
Files2 · source cached 10/2/2026, 12:38:58 AM
src/registry/components/copy-button/copy-button.tsx
"use client"
import type { ComponentProps } from "react"
import { CheckIcon, CircleXIcon, CopyIcon } from "lucide-react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
import type { CopyState } from "@/hooks/use-copy-to-clipboard"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Button } from "@/components/ui/button"
import { IconSwap, IconSwapItem } from "@/registry/components/icon-swap"
export type CopyStateIconProps = {
state: CopyState
/** Custom icon for idle state. */
idleIcon?: React.ReactNode
/** Custom icon for done state. */
doneIcon?: React.ReactNode
/** Custom icon for error state. */
errorIcon?: React.ReactNode
}
export function CopyStateIcon({
state,
idleIcon,
doneIcon,
errorIcon,
}: CopyStateIconProps) {
return (
<IconSwap>
<IconSwapItem key={state} as={motion.span}>
{state === "idle" && (idleIcon ?? <CopyIcon data-slot="idle-icon" />)}
{state === "done" && (doneIcon ?? <CheckIcon data-slot="done-icon" />)}
{state === "error" &&
(errorIcon ?? <CircleXIcon data-slot="error-icon" />)}
</IconSwapItem>
</IconSwap>
)
}
export type CopyButtonProps = ComponentProps<typeof Button> & {
/** The text to copy, or a function that returns the text. */
text: string | (() => string)
/** Called with the copied text on successful copy. */
onCopySuccess?: (text: string) => void
/** Called with the error if the copy operation fails. */
onCopyError?: (error: Error) => void
} & Omit<CopyStateIconProps, "state">
export function CopyButton({
className,
size = "icon",
children,
text,
idleIcon,
doneIcon,
errorIcon,
onClick,
onCopySuccess,
onCopyError,
...props
}: CopyButtonProps) {
const { state, copy } = useCopyToClipboard({
onCopySuccess,
onCopyError,
})
return (
<Button
className={cn("will-change-transform", className)}
size={size}
onClick={(e) => {
copy(text)
onClick?.(e)
}}
aria-label="Copy"
{...props}
>
<CopyStateIcon
state={state}
idleIcon={idleIcon}
doneIcon={doneIcon}
errorIcon={errorIcon}
/>
{children}
</Button>
)
}
src/hooks/use-copy-to-clipboard.ts
"use client"
import { useCallback, useRef, useState } from "react"
import { useTiks } from "@rexa-developer/tiks/react"
import { useWebHaptics } from "web-haptics/react"
export type CopyState = "idle" | "done" | "error"
export type UseCopyToClipboardOptions = {
onCopySuccess?: (text: string) => void
onCopyError?: (error: Error) => void
resetDelay?: number
}
export function useCopyToClipboard({
onCopySuccess,
onCopyError,
resetDelay = 1500,
}: UseCopyToClipboardOptions = {}) {
const [state, setState] = useState<CopyState>("idle")
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { trigger: haptic } = useWebHaptics()
const { success: tiksSuccess, error: tiksError } = useTiks()
const copy = useCallback(
async (text: string | (() => string)) => {
// Clear any pending reset
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current)
}
try {
const finalText = typeof text === "function" ? text() : text
await navigator.clipboard.writeText(finalText)
setState("done")
haptic("success")
tiksSuccess()
onCopySuccess?.(finalText)
} catch (error) {
setState("error")
haptic("error")
tiksError()
onCopyError?.(error instanceof Error ? error : new Error("Copy failed"))
} finally {
// Schedule reset to idle
resetTimeoutRef.current = setTimeout(() => {
setState("idle")
}, resetDelay)
}
},
[onCopySuccess, onCopyError, haptic, tiksSuccess, tiksError, resetDelay]
)
return { state, copy } as const
}