Command Palette

Search for a command to run...

0GitHub stars
Components

Copy Button

Copy text to clipboard with visual, haptic, and audio feedback.

Loading…

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-button

Manual Installation

Install the following dependencies

npm install cn class-variance-authority lucide-react motion @base-ui/react

Add a cn helper

lib/utils.ts
export { cn } from "cn"

Install the required components

Copy and paste the following code into your project

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
}
components/icon-swap.tsx
"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

Was this helpful?

Command Palette

Search for a command to run...