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