Copy Feedback

Nothing distinguishes a successful copy from a missed click, so people press Copy repeatedly and still do not trust that it worked.

FeedbackProductivityAuthenticationDashboardbeginner
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
7B38BD2
npx shadcn@latest add https://design-recreation.vercel.app/r/copy-feedback.json

The problem

Nothing distinguishes a successful copy from a click that missed. People press Copy two or three times and still paste with their fingers crossed. A confirmation fixes that — but a confirmation that vanishes on its own schedule raises a second question: is it done, or is it stuck? Letting the fill run for exactly as long as the message stays answers both at once.

Use cases

  • Invite codes, API keys, and one-time passwords
  • Share links in a permissions or publish flow
  • Any short value the user is about to paste somewhere else

Not for: long values that need to stay readable while being copied. The pill truncates, and a truncated secret is hard to verify — use a field with a copy affordance beside it.

Installation

bash
npm install motion lucide-react

Then copy CopyFeedback.tsx. It depends only on @/lib/cn and @/lib/motion.

Usage

tsx
import CopyFeedback from "@/patterns/copy-feedback/CopyFeedback";

<CopyFeedback value="7B38BD2" confirmLabel="Code copied!" />;

API

PropTypeDefaultDescription
valuestring—Placed on the clipboard, and shown when idle.
actionLabelstring"Copy"Trigger text.
confirmLabelstring"Copied!"Confirmation text.
errorLabelstring"Press ⌘C to copy"Shown if the clipboard write is refused.
holdMsnumber2000How long the confirmation is held. Drives the fill too.
onCopy(value: string) => void—Fires after a successful write.
classNamestring—Merged onto the root.

Keyboard

KeyAction
TabFocus the copy button
Enter / SpaceCopy

Focus stays on the trigger throughout. The confirmation replaces the pill's contents visually but never moves focus, so repeated copies need no re-targeting.

Accessibility

It now actually copies. The original set a boolean and showed "Copied!" without ever touching the clipboard — a confirmation for something that did not happen, which is worse than no confirmation at all.

Clipboard access can be refused by permissions policy or an insecure origin. That case gets its own state and message rather than being swallowed, so the user is told to copy manually instead of pasting nothing.

The confirmation is aria-hidden and carried by a role="status" live region instead. Visually the pill's contents swap; to assistive technology this is one control reporting an outcome, not two elements appearing and disappearing.

Under prefers-reduced-motion the timer fill is not rendered at all — a bar sweeping the full width of the control is exactly the kind of large-area movement the preference is asking about. The text still swaps, so the confirmation is never lost.

Known limitation: the fill is decorative for sighted users only; there is no announced countdown. Announcing one would talk over the confirmation it is timing.

Performance

The fill animates scaleX from a left origin rather than width — a width animation reflows the pill every frame, a transform does not. linear easing is correct here and almost nowhere else in the library, because the bar represents real elapsed time.

holdMs drives both the fill duration and the reset timer, so the bar cannot finish before or after the message clears. Text swaps animate opacity, filter and scale only, with blur capped at 4px.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

CopyFeedback.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check } from "lucide-react";

import { cn } from "@/lib/cn";
import { duration, ease, spring } from "@/lib/motion";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface CopyFeedbackProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onCopy" | "onError"
> {
  /** The text placed on the clipboard, and shown when idle. */
  value: string;
  /** Trigger text. @default "Copy" */
  actionLabel?: string;
  /** Confirmation text. @default "Copied!" */
  confirmLabel?: string;
  /** Shown if the clipboard write is refused. @default "Press ⌘C to copy" */
  errorLabel?: string;
  /** How long the confirmation is held, ms. Drives the fill too. @default 2000 */
  holdMs?: number;
  onCopy?: (value: string) => void;
}

type Status = "idle" | "copied" | "error";

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A value with a copy control that confirms it worked.
 *
 * Without confirmation people press Copy two or three times, because nothing
 * distinguishes "copied" from "the click missed". The fill doubles as the
 * timer: it shows both that it worked and how long the confirmation will stay,
 * so the control never looks stuck.
 */
export function CopyFeedback({
  value,
  actionLabel = "Copy",
  confirmLabel = "Copied!",
  errorLabel = "Press ⌘C to copy",
  holdMs = 2000,
  onCopy,
  className,
  ...rest
}: CopyFeedbackProps) {
  const reduce = useReducedMotion();
  const [status, setStatus] = useState<Status>("idle");
  const timeoutRef = useRef<number | undefined>(undefined);

  useEffect(() => () => window.clearTimeout(timeoutRef.current), []);

  const copy = useCallback(async () => {
    // The original only flipped a boolean — it showed "Copied!" without ever
    // touching the clipboard. Confirming something that did not happen is
    // worse than no confirmation at all.
    try {
      await navigator.clipboard.writeText(value);
      setStatus("copied");
      onCopy?.(value);
    } catch {
      // Denied by permissions policy or an insecure origin. Say so instead of
      // claiming success, so the user knows to select the text manually.
      setStatus("error");
    }
    window.clearTimeout(timeoutRef.current);
    timeoutRef.current = window.setTimeout(() => setStatus("idle"), holdMs);
  }, [value, holdMs, onCopy]);

  const swapIn = reduce
    ? { duration: 0.01 }
    : {
        ...spring.snappy,
        filter: { duration: duration.standard, ease: ease.out },
      };
  const swapOut = reduce
    ? { duration: 0.01 }
    : { duration: duration.micro, ease: ease.in };

  return (
    <div
      className={cn("flex items-center justify-center", className)}
      {...rest}
    >
      <div className="relative flex h-11 w-[220px] items-center overflow-hidden rounded-full bg-surface-subtle shadow-resting">
        {/*
          The confirmation fill. scaleX from a left origin rather than an
          animated width — width reflows the pill on every frame, a transform
          does not. `linear` is correct here and almost nowhere else: the bar
          represents real elapsed time.
        */}
        <AnimatePresence>
          {status !== "idle" && !reduce ? (
            <motion.div
              key="fill"
              aria-hidden="true"
              initial={{ scaleX: 0 }}
              animate={{ scaleX: 1 }}
              exit={{ opacity: 0, transition: { duration: duration.fast } }}
              transition={{ duration: holdMs / 1000, ease: "linear" }}
              style={{ originX: 0, willChange: "transform" }}
              className={cn(
                "absolute inset-0",
                status === "error" ? "bg-danger/15" : "bg-surface-active",
              )}
            />
          ) : null}
        </AnimatePresence>

        <div className="relative z-10 w-full px-2">
          <AnimatePresence mode="popLayout" initial={false}>
            {status === "idle" ? (
              <motion.div
                key="idle"
                initial={{ opacity: 0, filter: "blur(4px)" }}
                animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
                exit={{
                  opacity: 0,
                  filter: "blur(4px)",
                  scale: 1.15,
                  // Exit carries its own timing; `exit` is not a valid Transition key.
                  transition: swapOut,
                }}
                transition={swapIn}
                className="flex w-full items-center justify-between gap-2"
              >
                <span className="truncate pl-2 font-semibold text-content-subtle">
                  {value}
                </span>
                <button
                  type="button"
                  onClick={copy}
                  className={cn(
                    "focus-ring shrink-0 cursor-pointer rounded-full bg-surface",
                    "relative px-3.5 py-1.5 text-sm font-semibold text-content shadow-resting",
                    // 32px visually; the pill it sits in has no room to grow.
                    "before:absolute before:-inset-x-0 before:-inset-y-1.5 before:content-['']",
                    "transition-colors hover:bg-surface-hover",
                  )}
                >
                  {actionLabel}
                </button>
              </motion.div>
            ) : (
              <motion.div
                key="result"
                aria-hidden="true"
                initial={{ opacity: 0, filter: "blur(4px)", scale: 0.9 }}
                animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
                exit={{
                  opacity: 0,
                  filter: "blur(4px)",
                  scale: 1.15,
                  // Exit carries its own timing; `exit` is not a valid Transition key.
                  transition: swapOut,
                }}
                transition={swapIn}
                className="flex w-full items-center justify-center gap-1.5"
              >
                {status === "copied" ? (
                  <Check
                    className="size-4 rounded-full bg-accent p-0.5 text-accent-content"
                    strokeWidth={2}
                  />
                ) : null}
                <span
                  className={cn(
                    "text-sm font-semibold",
                    status === "error" ? "text-danger" : "text-content",
                  )}
                >
                  {status === "copied" ? confirmLabel : errorLabel}
                </span>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>

      {/*
        The result is aria-hidden above and announced here instead. Visually
        the pill's contents are replaced; to a screen reader it is one control
        reporting an outcome, and the trigger keeps focus throughout.
      */}
      <span role="status" aria-live="polite" className="sr-only">
        {status === "copied"
          ? confirmLabel
          : status === "error"
            ? errorLabel
            : ""}
      </span>
    </div>
  );
}

export default CopyFeedback;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react)
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original interaction by @nitishkmrk. Rebuilt with a props API, a real clipboard write, an error state, screen-reader announcements, a transform-based timer, and reduced-motion handling.