shadimage
persianlabsui
uicopy-buttonsource: ok · hit

Copy Button

An icon button that copies text to the clipboard, morphs its icon to reflect the result, and anchors a confirmation toast to itself.

npx shadcn add "https://ui.persian-labs.ir/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 · light
lucidemorphicons

Registry dependencies

utils@persianlabsui/button@persianlabsui/tooltip@persianlabsui/toast

Files2 · source cached 10/8/2026, 5:57:45 PM

registry/base/ui/copy-button.tsx
"use client"

import { Check, Copy } from "lucide"
import { MorphIcon } from "morphicons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { AnchoredToastProvider, ToastPrimitive } from "@/components/ui/toast"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"

export interface CopyStateIconProps {
  copied: boolean
  idleIcon?: React.ReactNode
  doneIcon?: React.ReactNode
}

export function CopyStateIcon({
  copied,
  idleIcon,
  doneIcon,
}: CopyStateIconProps) {
  if (!copied && idleIcon) return idleIcon
  if (copied && doneIcon) return doneIcon

  return (
    <MorphIcon
      icon={copied ? Check : Copy}
      spring="snappy"
      className="size-4"
    />
  )
}

export interface CopyButtonProps extends React.ComponentProps<typeof Button> {
  text: string | (() => string)
  label?: string
  /**
   * Toast manager the "copied" feedback anchors to. By default CopyButton
   * brings its own manager and provider, so it works with zero setup. Pass
   * your app's own manager (alongside a matching ancestor
   * `AnchoredToastProvider`) only if you want this button's confirmation to
   * share state with other anchored toasts elsewhere on the page.
   */
  toastManager?: ReturnType<typeof ToastPrimitive.createToastManager>
  /** Which side of the button the confirmation toast anchors to. @default "top" */
  side?: NonNullable<ToastPrimitive.Positioner.Props["side"]>
  onCopySuccess?: (text: string) => void
  idleIcon?: React.ReactNode
  doneIcon?: React.ReactNode
}

/**
 * An icon button that copies `text` to the clipboard, morphs its icon to
 * reflect the result, and anchors a toast to itself confirming the copy —
 * self-contained, no `AnchoredToastProvider` wrapper needed.
 */
export function CopyButton({
  className,
  size = "icon",
  children,
  text,
  label = "کپی",
  toastManager,
  side = "top",
  idleIcon,
  doneIcon,
  onClick,
  onCopySuccess,
  ...props
}: CopyButtonProps) {
  const ref = React.useRef<HTMLButtonElement>(null)
  const pendingValue = React.useRef("")
  const timeout = 2000
  const ownManager = React.useMemo(
    () => ToastPrimitive.createToastManager(),
    []
  )
  const manager = toastManager ?? ownManager

  const { copyToClipboard, isCopied } = useCopyToClipboard({
    timeout,
    onCopy: () => {
      if (ref.current) {
        manager.add({
          title: "کپی شد!",
          timeout,
          positionerProps: { anchor: ref.current, side },
        })
      }
      onCopySuccess?.(pendingValue.current)
    },
  })

  const button = (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            ref={ref}
            className={cn("will-change-transform", className)}
            size={size}
            aria-label={label}
            onClick={(event) => {
              const value = typeof text === "function" ? text() : text
              pendingValue.current = value
              copyToClipboard(value)
              onClick?.(event)
            }}
            {...props}
          >
            <CopyStateIcon
              copied={isCopied}
              idleIcon={idleIcon}
              doneIcon={doneIcon}
            />
            {children}
          </Button>
        }
      />
      <TooltipContent sideOffset={8}>{label}</TooltipContent>
    </Tooltip>
  )

  if (toastManager) {
    return button
  }

  return (
    <AnchoredToastProvider toastManager={ownManager}>
      {button}
    </AnchoredToastProvider>
  )
}
registry/base/hooks/use-copy-to-clipboard.ts
"use client"

import * as React from "react"

export function useCopyToClipboard({
  timeout = 2000,
  onCopy,
}: {
  timeout?: number
  onCopy?: () => void
} = {}): { copyToClipboard: (value: string) => void; isCopied: boolean } {
  const [isCopied, setIsCopied] = React.useState(false)
  const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null)

  const copyToClipboard = (value: string): void => {
    if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
      return
    }

    if (!value) return

    navigator.clipboard.writeText(value).then(() => {
      if (timeoutIdRef.current) {
        clearTimeout(timeoutIdRef.current)
      }

      setIsCopied(true)

      if (onCopy) {
        onCopy()
      }

      if (timeout !== 0) {
        timeoutIdRef.current = setTimeout(() => {
          setIsCopied(false)
          timeoutIdRef.current = null
        }, timeout)
      }
    }, console.error)
  }

  // Cleanup timeout on unmount
  React.useEffect(() => {
    return (): void => {
      if (timeoutIdRef.current) {
        clearTimeout(timeoutIdRef.current)
      }
    }
  }, [])

  return { copyToClipboard, isCopied }
}

More from persianlabsui

Browse all components in this library →