Forms
Copy Button.
An icon button that copies a value to the clipboard and flashes a green check. Failures show a red cross instead of throwing.
import { CopyButton } from "@/components/ui/copy-button";
export default function CopyButtonDemo() {
return (
<div className="flex flex-wrap items-center gap-3">
<CopyButton value="pastebin get Xk2p9 hub" label="Copy install command" />
<CopyButton value="pastebin get Xk2p9 hub" label="Copy install command" variant="outline" size="icon" />
<CopyButton value="pastebin get Xk2p9 hub" variant="secondary" size="sm">
Copy command
</CopyButton>
</div>
);
}Installation#
With the CLI
$npx lanterncn add copy-buttonThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/copy-button. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install class-variance-authority lucide-reactCopy the source into your project
components/ui/copy-button.tsx"use client"; import * as React from "react"; import { type VariantProps } from "class-variance-authority"; import { CheckIcon, CopyIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; type CopyState = "idle" | "copied" | "error"; async function writeClipboard(text: string) { if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; } catch { // Permission denied or unfocused document: try the legacy path below. } } // Fallback for insecure origins, embedded frames and older browsers. 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 ok = document.execCommand("copy"); document.body.removeChild(area); if (!ok) throw new Error("Copy command failed"); } /** An icon button that copies `value` and flashes a green check. */ function CopyButton({ className, value, variant = "ghost", size = "icon-sm", label = "Copy", timeout = 1500, onCopy, onCopyError, onClick, children, ...props }: Omit<React.ComponentProps<"button">, "value"> & VariantProps<typeof buttonVariants> & { value: string; /** Accessible name before copying. */ label?: string; /** How long the copied state lasts, in ms. */ timeout?: number; onCopy?: (value: string) => void; onCopyError?: (error: unknown) => void; }) { const [state, setState] = React.useState<CopyState>("idle"); const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined); React.useEffect(() => () => clearTimeout(timer.current), []); async function handleClick(event: React.MouseEvent<HTMLButtonElement>) { onClick?.(event); if (event.defaultPrevented) return; try { await writeClipboard(value); setState("copied"); onCopy?.(value); } catch (error) { setState("error"); onCopyError?.(error); } clearTimeout(timer.current); timer.current = setTimeout(() => setState("idle"), timeout); } const Icon = state === "copied" ? CheckIcon : state === "error" ? XIcon : CopyIcon; const status = state === "copied" ? "Copied" : state === "error" ? "Copy failed" : ""; return ( <button type="button" data-slot="copy-button" data-state={state} aria-label={children ? undefined : state === "idle" ? label : status} className={cn( buttonVariants({ variant, size }), "data-[state=copied]:text-success data-[state=error]:text-destructive", className, )} onClick={handleClick} {...props} > <Icon key={state} aria-hidden="true" className={cn(state !== "idle" && "animate-in zoom-in-50 fade-in-0 duration-150")} /> {children} <span role="status" aria-live="polite" className="sr-only"> {status} </span> </button> ); } export { CopyButton };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand uses button.
Usage#
import { CopyButton } from "@/components/ui/copy-button";<CopyButton value="pastebin get Xk2p9 hub" label="Copy command" />Uses the Clipboard API and falls back to a hidden textarea on insecure origins. The result is announced through a polite live region.
Examples#
Next to a read-only field
"use client";
import { CopyButton } from "@/components/ui/copy-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function CopyButtonInput() {
const link = "lantern://hub.turtle/guestbook";
return (
<div className="grid w-full max-w-sm gap-2">
<Label htmlFor="share-link">Share link</Label>
<div className="flex gap-2">
<Input id="share-link" readOnly value={link} className="font-mono text-[13px]" onFocus={(e) => e.currentTarget.select()} />
<CopyButton value={link} label="Copy share link" variant="outline" size="icon" />
</div>
</div>
);
}