Copy Button
A clipboard-copy button with an animated icon swap between Copy and Check.
import { CopyButton } from '@/foundations/icons/copy-button';
export default function CopyButtonPreview() {
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-surface-raised px-3 py-2 font-mono text-foreground text-sm">
<span className="select-all">npm i playstack-ui</span>
<CopyButton value="npm i playstack-ui" />
</div>
);
} Dependencies
Source Code
'use client';
import { Check, Copy03 } from '@untitledui-pro/icons/solid';
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useRef, useState } from 'react';
import { MOTION_DURATION } from '@/foundations/setup/motion';
import {
IconButton,
type IconButtonProps,
} from '@/components/button';
import { cn } from '@/lib/utils/classnames';
interface CopyButtonBaseProps
extends Omit<IconButtonProps, 'children' | 'aria-label'> {
/** How long the success state stays visible, in milliseconds. */
resetMs?: number;
/** Accessible label while idle. Defaults to "Copy". */
copyLabel?: string;
/** Accessible label while showing the success state. Defaults to "Copied". */
copiedLabel?: string;
}
/**
* The copy source — exactly one of:
* - `value`: a string written to the clipboard directly.
* - `target`: a CSS selector; the matched element's `innerText` is copied,
* resolved at click time (use for copying rendered DOM, e.g. a code block).
*/
type CopyButtonProps = CopyButtonBaseProps &
({ value: string; target?: never } | { target: string; value?: never });
const CopyButton = (props: CopyButtonProps) => {
// `value` / `target` are mutually exclusive (see CopyButtonProps); pull both
// out so neither is spread onto the underlying IconButton.
const {
value,
target,
resetMs = 1500,
copyLabel = 'Copy',
copiedLabel = 'Copied',
variant = 'ghost',
size = 'sm',
onClick,
className,
...iconButtonProps
} = props as CopyButtonBaseProps & { value?: string; target?: string };
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
// `target` resolves the matched element's text at click time; `value` is
// copied as-is.
const text =
target != null
? (document.querySelector<HTMLElement>(target)?.innerText ?? '')
: (value ?? '');
void navigator.clipboard?.writeText(text);
setCopied(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), resetMs);
};
return (
<IconButton
aria-label={copied ? copiedLabel : copyLabel}
aria-live="polite"
variant={variant}
size={size}
onClick={handleClick}
className={cn('overflow-hidden', className)}
{...iconButtonProps}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? 'check' : 'copy'}
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: MOTION_DURATION.slow,
bounce: 0,
}}
className="grid place-items-center"
>
{copied ? <Check /> : <Copy03 />}
</motion.span>
</AnimatePresence>
</IconButton>
);
};
export type { CopyButtonProps };
export { CopyButton }; CopyButton writes a value to the clipboard when pressed and crossfades its
icon from Copy to Check for a brief success state. The icon transition uses a
spring with a small blur ramp so the swap feels physical rather than abrupt.
It builds on the project’s IconButton so size, variant, focus ring, and
press-scale stay consistent with every other button in the system.
API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
value | - | string | A string written to the clipboard when pressed. Provide either `value` or `target`, not both. |
target | - | string | A CSS selector; the matched element's `innerText` is copied, resolved at click time. Use for copying rendered DOM such as a code block. Provide either `value` or `target`, not both. |
resetMs | 1500 | number | How long the success state stays visible before reverting to Copy. |
copyLabel | "Copy" | string | Accessible label while the button is idle. |
copiedLabel | "Copied" | string | Accessible label while showing the success state. |
variant | "ghost" | IconButtonProps["variant"] | |
size | "sm" | IconButtonProps["size"] |
Any other prop accepted by IconButton (disabled, onClick, className,
ref, …) is forwarded.
Usage
import { CopyButton } from '@/foundations/icons/copy-button/copy-button';
// Copy a string directly.
<CopyButton value="npm i playstack-ui" />
// Or copy the rendered text of a DOM node by selector.
<CopyButton target="#snippet pre" />
The button resolves its source at the moment it’s clicked — value is copied
as-is, target reads the matched element’s innerText. Either way, wiring it
to dynamic content (a code snippet, a token, a generated URL) needs no extra
state on your side.
Previous
useTopLayer
Next
Morph Icon