ratneshc
componentcopy-buttonsource: ok · hit
Copy Button
A copy button with direction-aware icon transitions.
npx shadcn add "https://ratneshc.com/r/copy-button.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
https://ratneshc.com/r/use-copy-to-clipboard.json
Files1 · source cached 10/8/2026, 9:02:24 PM
src/registry/components/copy-button/copy-button.tsx
// Thanks @ncdai
"use client";
import type { ComponentProps } from "react";
import {
CancelCircleIcon,
Copy01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import type { HTMLMotionProps, Variants } from "motion/react";
import { Button } from "@/components/ui/button";
import type { CopyState } from "@/registry/hooks/use-copy-to-clipboard";
import { useCopyToClipboard } from "@/registry/hooks/use-copy-to-clipboard";
export const motionIconVariants: Variants = {
initial: (direction: number) => ({
opacity: 0,
scale: 0.8,
y: direction > 0 ? 10 : -10,
filter: "blur(2px)",
}),
animate: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" },
exit: (direction: number) => ({
opacity: 0,
scale: 0.8,
y: direction > 0 ? -10 : 10,
filter: "blur(2px)",
}),
};
export const motionIconProps: HTMLMotionProps<"span"> = {
variants: motionIconVariants,
initial: "initial",
animate: "animate",
exit: "exit",
transition: { duration: 0.15, ease: "easeOut" },
};
export type CopyStateIconProps = {
/** The current state of the copy operation. */
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) {
const direction = state === "idle" ? -1 : 1;
return (
<span className="relative flex items-center justify-center">
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
{state === "idle" ? (
<motion.span key="idle" {...motionIconProps} custom={direction}>
{idleIcon ?? <HugeiconsIcon icon={Copy01Icon} strokeWidth={2} />}
</motion.span>
) : state === "done" ? (
<motion.span key="done" {...motionIconProps} custom={direction}>
{doneIcon ?? <HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />}
</motion.span>
) : state === "error" ? (
<motion.span key="error" {...motionIconProps} custom={direction}>
{errorIcon ?? (
<HugeiconsIcon icon={CancelCircleIcon} strokeWidth={2} />
)}
</motion.span>
) : null}
</AnimatePresence>
</span>
);
}
export type CopyButtonProps = ComponentProps<typeof Button> & {
/** The text to copy, or a function that returns the text. */
text: string | (() => string);
/** Called when the text is successfully copied. */
onCopySuccess?: (text: string) => void;
/** Called when the copy operation fails. */
onCopyError?: (error: Error) => void;
} & Pick<CopyStateIconProps, "idleIcon" | "doneIcon" | "errorIcon">;
export function CopyButton({
size = "icon",
children,
text,
idleIcon,
doneIcon,
errorIcon,
onClick,
onCopySuccess,
onCopyError,
...props
}: CopyButtonProps) {
const { state, copy } = useCopyToClipboard({
onCopySuccess,
onCopyError,
});
return (
<Button
size={size}
onClick={(e) => {
copy(text);
onClick?.(e);
}}
aria-label="Copy"
{...props}
>
<CopyStateIcon
state={state}
idleIcon={idleIcon}
doneIcon={doneIcon}
errorIcon={errorIcon}
/>
{children}
</Button>
);
}