Agents (llms.txt)

Copy Button

A clipboard-copy button with an animated icon swap between Copy and Check.

npm i playstack-ui
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