FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Copy Button

Copy Button

pnpm dlx shadcn@latest add …/r/copy-button.json

An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".

  • Ghost
  • Outline
  • With text
import { CopyButton } from "@/components/ui/copy-button"

export function CopyCommand() {
  return <CopyButton value="npm install @fewtone/ui" />
}
variants.tsx
import { CopyButton } from "@/components/ui/copy-button";
import { useId } from "react";

export default function CopyButtonVariants() {
  const id = useId();
  return (
    <ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
      <li className="flex flex-col items-center gap-2">
        <CopyButton value="https://ui.fewtone.com" aria-describedby={`${id}-ghost`} />
        <span id={`${id}-ghost`} className="text-xs text-muted-foreground">
          Ghost
        </span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <CopyButton value="https://ui.fewtone.com" variant="outline" aria-describedby={`${id}-outline`} />
        <span id={`${id}-outline`} className="text-xs text-muted-foreground">
          Outline
        </span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <CopyButton value="https://ui.fewtone.com" variant="outline" copiedText="Copied">
          Copy link
        </CopyButton>
        <span className="text-xs text-muted-foreground">With text</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Add Button first.

  5. 5

    Copy the source into your project.

    components/ui/copy-button.tsx
    "use client";
    
    import { Button, type ButtonProps } from "@/components/ui/button";
    import { duration, ease, spring, swap } from "@/lib/motion";
    import { CopyIcon } from "lucide-react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react";
    
    /*
     * A Button that copies text to the clipboard (owner, 2026-10-14).
     * - Motion (.agents/rules/motion.md): the same swap as Loading Button, a blur with a 6px rise. The copy
     *   icon lifts away as a check rises in and draws itself. With reduced motion it only fades.
     * - Width never jumps: both states are laid out invisibly in one cell, so a label can change
     *   ("Copy link" → "Copied") without the button resizing.
     * - Icon only by default (ghost, icon size), named "Copy"; pass children for a label, and `icon={false}`
     *   for text only (the label still swaps through the blur).
     * - A polite live region says "Copied". Back to the copy icon after `resetAfter`.
     * - Uses the Clipboard API, falling back to a hidden textarea where it's unavailable (older browsers,
     *   pages not served over https).
     */
    
    export type CopyButtonProps = Omit<ButtonProps, "value" | "asChild"> & {
      /** The text to copy */
      value: string;
      /** Shown instead of the label once copied, for example "Copied" */
      copiedText?: ReactNode;
      /** Called with the text after it's copied */
      onCopy?: (value: string) => void;
      /** Milliseconds the check shows before the copy icon returns */
      resetAfter?: number;
      /** Show the copy icon and the check. Set false for a text-only button; it then needs a label. */
      icon?: boolean;
    };
    
    const CHECK = "M5 12.5l4.5 4.5L19 7.5";
    
    /** The check draws itself in; a 16px icon stroke, so drawing it costs nothing */
    function Check({ reduce }: { reduce: boolean }) {
      return (
        <svg viewBox="0 0 24 24" fill="none" aria-hidden>
          <motion.path
            d={CHECK}
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={{ pathLength: reduce ? 1 : 0 }}
            animate={{ pathLength: 1 }}
            transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }}
          />
        </svg>
      );
    }
    
    /** The Clipboard API, or a hidden textarea where it's unavailable */
    async function writeText(text: string) {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(text);
        return;
      }
      const area = document.createElement("textarea");
      area.value = text;
      area.setAttribute("readonly", "");
      area.style.position = "fixed";
      area.style.opacity = "0";
      document.body.appendChild(area);
      area.select();
      const copied = document.execCommand("copy");
      area.remove();
      if (!copied) throw new Error("Copy failed");
    }
    
    function CopyButton({
      value,
      copiedText,
      onCopy,
      resetAfter = 2000,
      icon = true,
      onClick,
      children,
      variant = "ghost",
      size,
      ...props
    }: CopyButtonProps) {
      const [copied, setCopied] = useState(false);
      // Counts copies, so copying again while the check shows starts its time over
      const [copies, setCopies] = useState(0);
      const reduce = useReducedMotion() ?? false;
      const mounted = useRef(true);
    
      useEffect(() => {
        mounted.current = true;
        return () => {
          mounted.current = false;
        };
      }, []);
    
      // The check gives way to the copy icon after a moment, counted from the latest copy
      // biome-ignore lint/correctness/useExhaustiveDependencies: each copy restarts the timer
      useEffect(() => {
        if (!copied) return;
        const timer = setTimeout(() => setCopied(false), resetAfter);
        return () => clearTimeout(timer);
      }, [copied, copies, resetAfter]);
    
      const content = (state: "idle" | "copied", live: boolean) => {
        const glyph = !icon ? null : live ? (
          state === "copied" ? (
            <Check reduce={reduce} />
          ) : (
            <CopyIcon aria-hidden />
          )
        ) : (
          <span className="size-(--size-icon-md) shrink-0" />
        );
        const label = state === "copied" ? (copiedText ?? children) : children;
        return (
          <>
            {glyph}
            {label}
          </>
        );
      };
    
      return (
        <>
          <Button
            data-slot="copy-button"
            data-state={copied ? "copied" : "idle"}
            variant={variant}
            size={size ?? (children ? "md" : "icon")}
            aria-label={children ? undefined : "Copy"}
            onClick={async (event: MouseEvent<HTMLButtonElement>) => {
              onClick?.(event);
              if (event.defaultPrevented) return;
              try {
                await writeText(value);
                if (!mounted.current) return;
                setCopied(true);
                setCopies((count) => count + 1);
                onCopy?.(value);
              } catch {
                // The clipboard can be blocked; the text is still on the page to select
              }
            }}
            {...props}
          >
            <span className="grid">
              {/* Both states, invisible, in one cell: the button is as wide as the wider */}
              {(["idle", "copied"] as const).map((state) => (
                <span
                  key={state}
                  aria-hidden
                  className="invisible col-start-1 row-start-1 flex items-center justify-center gap-2"
                >
                  {content(state, false)}
                </span>
              ))}
              <AnimatePresence initial={false}>
                <motion.span
                  key={copied ? "copied" : "idle"}
                  {...(reduce
                    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
                    : swap)}
                  transition={
                    reduce
                      ? { duration: duration.fast }
                      : { duration: duration.base, ease: ease.out, y: spring.snappy }
                  }
                  className="col-start-1 row-start-1 flex items-center justify-center gap-2"
                >
                  {content(copied ? "copied" : "idle", true)}
                </motion.span>
              </AnimatePresence>
            </span>
          </Button>
          <span aria-live="polite" className="sr-only">
            {copied ? "Copied" : ""}
          </span>
        </>
      );
    }
    
    export { CopyButton };
  6. 6

    Update the import paths to match your project.

Examples

A command

Next to a command or a snippet, in a small size.

npx shadcn@latest add https://ui.fewtone.com/r/copy-button.json
command.tsx
import { CopyButton } from "@/components/ui/copy-button";

const command = "npx shadcn@latest add https://ui.fewtone.com/r/copy-button.json";

export default function CopyButtonCommand() {
  return (
    <div className="flex w-full max-w-lg items-center gap-2 rounded-lg bg-muted py-1 ps-4 pe-1">
      <code className="min-w-0 flex-1 truncate font-mono text-sm">{command}</code>
      <CopyButton value={command} size="icon-sm" aria-label="Copy the command" />
    </div>
  );
}

Next to a field

For links and keys people need to paste somewhere else.

input.tsx
import { CopyButton } from "@/components/ui/copy-button";
import { Input } from "@/components/ui/input";
import { useId } from "react";

const link = "https://ui.fewtone.com/invite/k3x9";

export default function CopyButtonInput() {
  const id = useId();
  return (
    <div className="grid w-full max-w-sm gap-2">
      <label htmlFor={id} className="text-sm font-medium">
        Invite link
      </label>
      <div className="flex gap-2">
        <Input id={id} value={link} readOnly />
        <CopyButton value={link} variant="outline" copiedText="Copied">
          Copy
        </CopyButton>
      </div>
    </div>
  );
}

API reference

Also accepts every Button prop except asChild. Uses the Clipboard API, falling back to a hidden textarea where it's unavailable. A polite live region says "Copied".

PropTypeDefaultDescription
valuestring–The text to copy.
copiedText?ReactNode–Shown instead of the label once copied.
onCopy?(value: string) => void–Called after the text is copied.
resetAfter?number2000Milliseconds the check shows before the copy icon returns.
icon?booleantrueShows the copy icon and the check. Set false for a text-only button with a label.
variant?Button variant"ghost"The button style.
size?Button size"icon", or "md" with a labelThe button size.

Related components

  • Button

    An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.

  • Loading
    Loading Button

    An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.

  • Input

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.