Copy Button
Copy-to-clipboard button with a blur-and-scale copy-to-check icon morph, optional rolling Copy/Copied label, execCommand fallback, error state, and screen reader announcements.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @karaui/copy-button
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/copy-button.tsx
"use client";
// karaui.kamisama-v0.my.id/components/motion/copy-button
import { Check, Copy, X } from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type CopyButtonStatus = "idle" | "copied" | "error";
export type CopyButtonVariant = "secondary" | "ghost" | "outline" | "primary";
export type CopyButtonSize = "sm" | "md" | "icon";
export interface CopyButtonLabels {
idle: string;
copied: string;
error: string;
}
export interface CopyButtonProps
extends Omit<
HTMLMotionProps<"button">,
"value" | "children" | "onCopy" | "onError" | "onClick"
> {
/** Text to copy, or a (possibly async) getter evaluated on click. */
value: string | (() => string | Promise<string>);
/** Show a text label next to the icon that swaps with the status. */
showLabel?: boolean;
/** Label text per status. Also used for the accessible name and announcement. */
labels?: Partial<CopyButtonLabels>;
/** How long the copied/error state lingers before resetting, in ms. */
timeout?: number;
variant?: CopyButtonVariant;
size?: CopyButtonSize;
/** Override the idle / copied / error icons. */
icons?: Partial<Record<CopyButtonStatus, ReactNode>>;
onCopy?: (text: string) => void;
onCopyError?: (error: unknown) => void;
onStatusChange?: (status: CopyButtonStatus) => void;
}
const DEFAULT_LABELS: CopyButtonLabels = {
idle: "Copy",
copied: "Copied",
error: "Failed",
};
const VARIANT_CLASS: Record<CopyButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"border border-border bg-card text-muted-foreground hover:text-foreground",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
outline:
"border border-border bg-transparent text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<CopyButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
icon: "size-8 rounded-lg",
};
const SWAP_BLUR = "blur(6px)";
const ICON_VARIANTS: Variants = {
initial: { opacity: 0, scale: 0.3, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
scale: 0.3,
filter: SWAP_BLUR,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
const LABEL_VARIANTS: Variants = {
initial: { opacity: 0, y: "60%", filter: SWAP_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
y: "-60%",
filter: SWAP_BLUR,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
const FADE_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.15 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
/** `document.execCommand("copy")` fallback for insecure contexts and old browsers. */
function legacyCopy(text: string) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
const selection = document.getSelection();
const previous =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} finally {
document.body.removeChild(textarea);
if (previous && selection) {
selection.removeAllRanges();
selection.addRange(previous);
}
}
if (!ok) throw new Error("Copy command was rejected");
}
/** Copy text with the async Clipboard API, falling back to execCommand. */
export async function copyToClipboard(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Permission denied or insecure context — try the legacy path.
}
}
legacyCopy(text);
}
export function CopyButton({
value,
showLabel = false,
labels,
timeout = 1600,
variant = "secondary",
size,
icons,
onCopy,
onCopyError,
onStatusChange,
className,
disabled,
type = "button",
...props
}: CopyButtonProps) {
const reduce = useReducedMotion() ?? false;
const [status, setStatus] = useState<CopyButtonStatus>("idle");
const timer = useRef<number | undefined>(undefined);
const text = { ...DEFAULT_LABELS, ...labels };
const resolvedSize = size ?? (showLabel ? "sm" : "icon");
useEffect(
() => () => {
if (timer.current) window.clearTimeout(timer.current);
},
[],
);
const update = useCallback(
(next: CopyButtonStatus) => {
setStatus(next);
onStatusChange?.(next);
},
[onStatusChange],
);
const handleClick = async () => {
if (timer.current) window.clearTimeout(timer.current);
try {
const resolved = typeof value === "function" ? await value() : value;
await copyToClipboard(resolved);
update("copied");
onCopy?.(resolved);
} catch (error) {
update("error");
onCopyError?.(error);
}
timer.current = window.setTimeout(() => update("idle"), timeout);
};
const iconNode: Record<CopyButtonStatus, ReactNode> = {
idle: icons?.idle ?? <Copy />,
copied: icons?.copied ?? <Check className="text-(--color-success)" />,
error: icons?.error ?? <X className="text-destructive" />,
};
const iconVariants = reduce ? FADE_VARIANTS : ICON_VARIANTS;
const labelVariants = reduce ? FADE_VARIANTS : LABEL_VARIANTS;
return (
<>
<motion.button
type={type}
disabled={disabled}
onClick={handleClick}
whileTap={reduce || disabled ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
aria-label={showLabel ? undefined : text[status]}
data-status={status}
className={cn(
"relative inline-flex shrink-0 select-none items-center justify-center font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[resolvedSize],
className,
)}
{...props}
>
<span aria-hidden="true" className="relative grid size-3.5 place-items-center [&_svg]:size-3.5">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={status}
variants={iconVariants}
initial="initial"
animate="animate"
exit="exit"
className="col-start-1 row-start-1 grid place-items-center"
>
{iconNode[status]}
</motion.span>
</AnimatePresence>
</span>
{showLabel ? (
<span className="relative grid overflow-hidden">
{/* Every label sits invisibly in the same cell so the button keeps
the width of the longest one and never reflows mid-swap. */}
{(Object.keys(text) as CopyButtonStatus[]).map((key) => (
<span
key={key}
aria-hidden="true"
className="invisible col-start-1 row-start-1 whitespace-nowrap"
>
{text[key]}
</span>
))}
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={status}
variants={labelVariants}
initial="initial"
animate="animate"
exit="exit"
className="col-start-1 row-start-1 whitespace-nowrap text-left"
>
{text[status]}
</motion.span>
</AnimatePresence>
</span>
) : null}
</motion.button>
{/* Outside the button so the announcement never joins its accessible name. */}
<span aria-live="polite" className="sr-only">
{status === "copied"
? `${text.copied} to clipboard`
: status === "error"
? `${text.error} to copy`
: ""}
</span>
</>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
valuestring | (() => string | Promise<string>)Text to copy, or a (possibly async) getter evaluated on click.
—showLabel?booleanShow a text label next to the icon that swaps with the status.
falselabels?Partial<CopyButtonLabels>Label text per status. Also used for the accessible name and announcement.
—timeout?numberHow long the copied/error state lingers before resetting, in ms.
1600variant?"primary" | "secondary" | "ghost" | "outline"secondarysize?"sm" | "md" | "icon"—icons?Partial<Record<CopyButtonStatus, ReactNode>>Override the idle / copied / error icons.
—onCopy?((text: string) => void)—onCopyError?((error: unknown) => void)—onStatusChange?((status: CopyButtonStatus) => void)—className?string—Updated