Copy Button

Click-to-copy utility with confirmation feedback and a useCopyToClipboard hook.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/copy-button.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { Check, Copy } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../tooltip/tooltip"; const FALLBACK_TIMEOUT_MS = 2000; /** * Options for {@link useCopyToClipboard}. * * @public */ export type UseCopyToClipboardOptions = { /** Milliseconds the `copied` flag stays true after a successful copy. */ timeout?: number; }; /** * Return shape for {@link useCopyToClipboard}. * * @public */ export type UseCopyToClipboardResult = { /** True for `timeout` ms after the most recent successful copy. */ copied: boolean; /** Writes `value` to the clipboard. Resolves to `true` on success. */ copy: (value: string) => Promise<boolean>; /** Clears the `copied` flag and pending timer. */ reset: () => void; }; /** * React hook that copies arbitrary strings to the clipboard with a transient * `copied` flag suitable for visual feedback. * * @example * ```tsx * const { copied, copy } = useCopyToClipboard() * <button onClick={() => copy(apiKey)}>{copied ? "Copied!" : "Copy"}</button> * ``` * * @public */ export function useCopyToClipboard( options: UseCopyToClipboardOptions = {}, ): UseCopyToClipboardResult { const { timeout = FALLBACK_TIMEOUT_MS } = options; const [copied, setCopied] = useState(false); const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); useEffect(() => { return () => { if (timerRef.current !== undefined) { clearTimeout(timerRef.current); } }; }, []); const reset = useCallback(() => { if (timerRef.current !== undefined) clearTimeout(timerRef.current); setCopied(false); }, []); const copy = useCallback( async (value: string): Promise<boolean> => { try { if ( typeof navigator === "undefined" || typeof navigator.clipboard?.writeText !== "function" ) { return false; } await navigator.clipboard.writeText(value); if (timerRef.current !== undefined) clearTimeout(timerRef.current); setCopied(true); timerRef.current = setTimeout(() => { setCopied(false); }, timeout); return true; } catch { return false; } }, [timeout], ); return { copied, copy, reset }; } /** * Visual variant for {@link CopyButton}. * * - `icon` — compact icon-style button (default), suitable for toolbars. * - `inline` — small button sized to sit next to short inline text. * - `button` — full button with text label, suitable for primary CTAs. * * @public */ export type CopyButtonVariant = "button" | "icon" | "inline"; type ButtonElementProps = ComponentPropsWithoutRef<"button">; /** * Props for {@link CopyButton}. * * @public */ export type CopyButtonProps = { /** Tooltip + announcement text after a successful copy. Defaults to `"Copied!"`. */ copiedLabel?: string; /** Class name forwarded to the rendered icon. */ iconClassName?: string; /** Tooltip + accessible label before copy. Defaults to `"Copy"`. */ label?: string; /** Set to `false` to suppress the tooltip. */ showTooltip?: boolean; /** Milliseconds the success state persists. Defaults to 2000. */ timeout?: number; /** String to write to the clipboard when clicked. */ value: string; /** Visual variant. */ variant?: CopyButtonVariant; } & Omit<ButtonElementProps, "value">; function CopyIcon({ className, copied, size, }: { className?: string; copied: boolean; size: "sm" | "xs"; }): ReactNode { const Icon = copied ? Check : Copy; const sizeClass = size === "xs" ? "size-3" : "size-4"; return <Icon aria-hidden="true" className={cn(sizeClass, className)} />; } type TriggerProps = Omit< CopyButtonProps, "copiedLabel" | "showTooltip" | "timeout" > & { accessibleLabel: string; copied: boolean; copiedText: string; onClickHandler: (event: ReactMouseEvent<HTMLButtonElement>) => void; }; const ButtonTrigger = forwardRef<HTMLButtonElement, TriggerProps>( ( { accessibleLabel, className, copied, copiedText, iconClassName, label = "Copy", onClick: _onClick, onClickHandler, value: _value, variant = "icon", ...rest }, ref, ) => { if (variant === "button") { return ( <Button aria-label={accessibleLabel} className={cn(className)} onClick={onClickHandler} ref={ref} size="sm" type="button" variant="outline" {...rest} > <CopyIcon className={iconClassName} copied={copied} size="sm" /> <span>{copied ? copiedText : label}</span> </Button> ); } if (variant === "inline") { return ( <Button aria-label={accessibleLabel} className={cn( "size-6 align-middle text-muted-foreground hover:text-foreground", className, )} onClick={onClickHandler} ref={ref} size="icon" type="button" variant="ghost" {...rest} > <CopyIcon className={iconClassName} copied={copied} size="xs" /> </Button> ); } return ( <Button aria-label={accessibleLabel} className={cn("size-8", className)} onClick={onClickHandler} ref={ref} size="icon" type="button" variant="ghost" {...rest} > <CopyIcon className={iconClassName} copied={copied} size="sm" /> </Button> ); }, ); ButtonTrigger.displayName = "CopyButton.Trigger"; /** * Click-to-copy button with confirmation feedback. * * Composes {@link Button} and {@link Tooltip}. Copies `value` to the clipboard * via the async Clipboard API and announces success to assistive tech via a * visually hidden status region. * * @example * ```tsx * <CopyButton value={apiKey} /> * <CopyButton value={url} variant="button" label="Copy link" /> * <span>ID: usr_42 <CopyButton value="usr_42" variant="inline" /></span> * ``` * * @public */ export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>( ( { "aria-label": ariaLabelOverride, copiedLabel = "Copied!", label = "Copy", onClick, showTooltip = true, timeout = FALLBACK_TIMEOUT_MS, value, ...rest }, ref, ) => { const { copied, copy } = useCopyToClipboard({ timeout }); const handleClick = useCallback( (event: ReactMouseEvent<HTMLButtonElement>) => { onClick?.(event); if (event.defaultPrevented) return; void copy(value); }, [copy, onClick, value], ); const accessibleLabel = ariaLabelOverride ?? (copied ? copiedLabel : label); const tooltipText = copied ? copiedLabel : label; const trigger: ReactElement = ( <ButtonTrigger {...rest} accessibleLabel={accessibleLabel} copied={copied} copiedText={copiedLabel} label={label} onClickHandler={handleClick} ref={ref} value={value} /> ); const liveRegion = ( <span aria-live="polite" className="sr-only" role="status"> {copied ? copiedLabel : ""} </span> ); if (!showTooltip) { return ( <> {trigger} {liveRegion} </> ); } return ( <TooltipProvider delayDuration={200}> <Tooltip> <TooltipTrigger asChild>{trigger}</TooltipTrigger> <TooltipContent>{tooltipText}</TooltipContent> </Tooltip> {liveRegion} </TooltipProvider> ); }, ); CopyButton.displayName = "CopyButton";

Dependencies

  • @vllnt/ui@^0.2.1
  • lucide-react