Copy Button
Copy text to clipboard with visual, haptic, and audio feedback.
Features
- Animated icon transitions across idle, success, and error states.
- Haptic feedback confirms copy success on supported devices.
- UI sounds play distinct success and error cues.
- Accepts a string or function for dynamic content.
- Callback support for success and error events.
Installation
Install-Module cnpx -Scope CurrentUser -Force; Import-Module cnpx; cnpx add @chadnpc/copy-buttonManual Installation
Install the following dependencies
npm install cn class-variance-authority lucide-react motion @base-ui/reactAdd a cn helper
export { cn } from "cn"Install the required components
Copy and paste the following code into your project
"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
}"use client"
import type { AnimatePresenceProps, HTMLMotionProps } from "motion/react"
import { AnimatePresence, motion } from "motion/react"
export function IconSwap(props: React.PropsWithChildren<AnimatePresenceProps>) {
return <AnimatePresence mode="popLayout" initial={false} {...props} />
}
type MotionElement = typeof motion.div | typeof motion.span
export function IconSwapItem({
as: Component = motion.div,
...props
}: HTMLMotionProps<"div"> & {
as?: MotionElement
}) {
return (
<Component
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{
type: "spring",
duration: 0.3,
bounce: 0,
}}
{...props}
/>
)
}"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 "@/projects/ui/button"
import { IconSwap, IconSwapItem } from "@/projects/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>
)
}Update the import paths to match your project setup
Usage
import { CopyButton } from "@/components/copy-button"<CopyButton text="Some text to copy" />API reference
CopyButton
Extends Button with clipboard functionality.
Prop
Type
See shadcn/ui documentation for more information.
References