bottega

primitives

Copy Button

A tactile copy-to-clipboard button that morphs its clipboard icon into a checkmark via Motion's SVG pathLength animation — the check draws itself in as the clipboard fades out, so the confirmation feels physically written rather than swapped. Click triggers a spring micro-bounce (a pressed-key feel) and the background trails to bg-accent; failures shake and fire onError. Icon-only and fully accessible: native button semantics, focus-visible ring, Enter/Space, and an aria-label that flips to 'Copied!' in the success window. Forwards its ref to the underlying button element (both the animated and reduced-motion branches), so callers can measure or position relative to it (e.g. a confetti burst origin).


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/copy-button

Usage

Usagetsx
<CopyButton />

Props

Standard props — see source.


Source

copy-button.tsxtsx
"use client";

import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import {
  motion,
  useAnimationControls,
  useReducedMotion,
} from "motion/react";
import type { HTMLMotionProps } from "motion/react";

type Size = "sm" | "md" | "lg";
type Variant = "ghost" | "outline";

export type CopyButtonProps = Omit<
  HTMLMotionProps<"button">,
  "style" | "value"
> & {
  /** Text to copy to clipboard. Required. */
  value: string;
  /** ms to hold the success state before reverting. Default 2000. */
  timeout?: number;
  /** Fired after a successful copy. */
  onCopy?: (value: string) => void;
  /** Fired when clipboard write fails. */
  onError?: (err: unknown) => void;
  /** Icon + hit-area size. Default 'md'. */
  size?: Size;
  /** Visual treatment. 'ghost' (default) = no border; 'outline' = border ring. */
  variant?: Variant;
};

const SIZES: Record<
  Size,
  { box: string; icon: number; stroke: number; radius: string }
> = {
  // radius: tight pill fit at sm, full radius at md/lg
  sm: {
    box: "h-7 w-7",
    icon: 16,
    stroke: 1.5,
    radius: "rounded-[calc(var(--radius)-2px)]",
  },
  md: { box: "h-8 w-8", icon: 16, stroke: 1.5, radius: "rounded-[var(--radius)]" },
  lg: { box: "h-10 w-10", icon: 20, stroke: 1.75, radius: "rounded-[var(--radius)]" },
};

// Motion JS timings are SECONDS (not the --duration CSS token, which is ~300ms UI).
const PRESS_SPRING = { type: "spring" as const, stiffness: 400, damping: 17 };
// custom cubic-bezier overshoot so the check tip "arrives" with authority
const DRAW_EASE = [0.22, 1, 0.36, 1] as const;
const DRAW_DURATION = 0.35;
const UNDRAW_DURATION = 0.2;
const FADE_DURATION = 0.25;

export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(
  function CopyButton(
    {
      value,
      timeout = 2000,
      onCopy,
      onError,
      size = "md",
      variant = "ghost",
      className = "",
      "aria-label": ariaLabel,
      onClick: consumerOnClick,
      ...props
    },
    ref,
  ) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);

  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animate = mounted && !reduce;

  const [copied, setCopied] = useState(false);
  const [errored, setErrored] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const controls = useAnimationControls();

  useEffect(
    () => () => {
      if (timer.current) clearTimeout(timer.current);
    },
    [],
  );

  const bounce = useCallback(async () => {
    if (!animate) return;
    // scale 0.94 → 1.02 → 1.0, springs into a physical key-press feel
    await controls.start({ scale: 1.02, transition: PRESS_SPRING });
    await controls.start({ scale: 1, transition: PRESS_SPRING });
  }, [animate, controls]);

  const shake = useCallback(async () => {
    if (!animate) {
      // reduced motion: no shake, error still surfaces via state/aria
      return;
    }
    // translate(-2 → 2) × 2 cycles, transform-only
    await controls.start({
      x: [0, -2, 2, -2, 2, 0],
      transition: { duration: 0.08 * 2, ease: "linear" },
    });
    controls.set({ x: 0 });
  }, [animate, controls]);

  const handleClick = useCallback(
    async (e: React.MouseEvent<HTMLButtonElement>) => {
      consumerOnClick?.(e);
      if (e.defaultPrevented) return;
      if (timer.current) clearTimeout(timer.current);
      setErrored(false);
      void bounce();
      try {
        await navigator.clipboard.writeText(value);
        setCopied(true);
        onCopy?.(value);
        timer.current = setTimeout(() => setCopied(false), timeout);
      } catch (err) {
        setErrored(true);
        onError?.(err);
        void shake();
        timer.current = setTimeout(() => setErrored(false), timeout);
      }
    },
    [consumerOnClick, bounce, shake, value, timeout, onCopy, onError],
  );

  const s = SIZES[size];
  const label = copied ? "Copied!" : (ariaLabel ?? "Copy to clipboard");

  const root = [
    "relative inline-flex items-center justify-center",
    "cursor-pointer select-none",
    s.box,
    s.radius,
    "text-foreground",
    // bg-accent on success trails the icon; color is information so it applies
    // even under reduced motion (handled by the CSS transition below).
    copied ? "bg-accent" : "bg-background hover:bg-muted",
    variant === "outline" ? "border border-border" : "",
    "transition-colors duration-[var(--duration)] ease-[var(--ease)]",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
    className,
  ]
    .filter(Boolean)
    .join(" ");

  // Geometry tuned to a 24x24 viewBox; rendered at icon size.
  const dim = { width: s.icon, height: s.icon };

  // Reduced-motion / pre-mount: instant opacity cut, no path animation, no spring.
  if (!animate) {
    return (
      <button
        {...stripMotionProps(props)}
        ref={ref}
        type="button"
        onClick={handleClick}
        aria-label={label}
        className={root}
      >
        <span className="relative" style={dim}>
          <ClipboardSvg
            size={s.icon}
            stroke={s.stroke}
            style={{ opacity: copied ? 0 : 1 }}
          />
          <CheckSvg
            size={s.icon}
            stroke={s.stroke}
            style={{ opacity: copied ? 1 : 0 }}
            drawn={copied}
          />
        </span>
      </button>
    );
  }

  return (
    <motion.button
      {...props}
      ref={ref}
      type="button"
      onClick={handleClick}
      aria-label={label}
      className={root}
      animate={controls}
      initial={{ scale: 1, x: 0 }}
      whileTap={{ scale: 0.94 }}
      style={{ transformOrigin: "center" }}
    >
      <span className="relative" style={dim}>
        {/* Clipboard layer — fades out as the check draws in */}
        <motion.span
          className="absolute inset-0"
          initial={false}
          animate={{ opacity: copied ? 0 : 1, scale: copied ? 1 : 1 }}
          whileHover={copied ? undefined : { scale: 1.05 }}
          transition={{ duration: FADE_DURATION, ease: "easeOut" }}
        >
          <ClipboardSvg size={s.icon} stroke={s.stroke} animated />
        </motion.span>

        {/* Check layer — pathLength draws in (0→1) / undraws (1→0) */}
        <motion.span
          className="absolute inset-0"
          initial={false}
          animate={{ opacity: copied ? 1 : 0 }}
          transition={{ duration: copied ? 0.12 : FADE_DURATION, ease: "easeOut" }}
        >
          <CheckSvg
            size={s.icon}
            stroke={s.stroke}
            animated
            pathLength={copied ? 1 : 0}
            drawDuration={copied ? DRAW_DURATION : UNDRAW_DURATION}
          />
        </motion.span>
      </span>
    </motion.button>
  );
  },
);

CopyButton.displayName = "CopyButton";

/** Strip motion-only gesture props before spreading onto a native button. */
function stripMotionProps(props: Record<string, unknown>) {
  const {
    whileHover: _wh,
    whileTap: _wt,
    whileFocus: _wf,
    whileDrag: _wd,
    whileInView: _wi,
    initial: _i,
    animate: _a,
    exit: _e,
    transition: _t,
    variants: _v,
    ...rest
  } = props;
  void _wh;
  void _wt;
  void _wf;
  void _wd;
  void _wi;
  void _i;
  void _a;
  void _e;
  void _t;
  void _v;
  return rest;
}

type IconProps = {
  size: number;
  stroke: number;
  style?: React.CSSProperties;
};

function ClipboardSvg({
  size,
  stroke,
  style,
  animated,
}: IconProps & { animated?: boolean }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={stroke}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      style={style}
      className={animated ? "absolute inset-0" : undefined}
    >
      <rect x="8" y="2" width="8" height="4" rx="1" />
      <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
    </svg>
  );
}

function CheckSvg({
  size,
  stroke,
  style,
  animated,
  drawn,
  pathLength,
  drawDuration = 0.35,
}: IconProps & {
  animated?: boolean;
  drawn?: boolean;
  pathLength?: number;
  drawDuration?: number;
}) {
  // The check starts at the same stroke origin as the clipboard so it reads as
  // a morph, not a swap: it "writes" the confirmation in.
  if (!animated) {
    return (
      <svg
        width={size}
        height={size}
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={stroke}
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        style={style}
        className="absolute inset-0"
      >
        <path
          d="M5 12.5l4.5 4.5L19 7"
          style={{ opacity: drawn ? 1 : 0 }}
        />
      </svg>
    );
  }
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={stroke}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      style={style}
      className="absolute inset-0"
    >
      <motion.path
        d="M5 12.5l4.5 4.5L19 7"
        initial={false}
        animate={{ pathLength: pathLength ?? 0 }}
        transition={{ duration: drawDuration, ease: DRAW_EASE }}
      />
    </svg>
  );
}

Dependencies

  • motion
  • @bottega/tokens