← ui

copy

ui

copy to the clipboard, confirmed in place, never with a toast

npx shadcn add @ag/copy
Already installed? Preview an update
npx shadcn add @ag/copy --dry-run

Your installed source stays unchanged until you update it. How updates work

also called copy to clipboard

shelf text & code

used by code, deployment, email-address, browser, lightbox

beside a command

npx shadcn add @ag/copy
note.txt

a little context goes a long way

Reads the current value when pressed. An empty field leaves your clipboard intact.

the demo above, verbatim

copy.demo.tsx
import { Sample } from "@/app/samples"
import { Code } from "@/registry/base-nova/ui/code"
import { Copy } from "@/registry/base-nova/ui/copy"
import { CopyLiveExample } from "@/registry/base-nova/ui/copy-live.demo"

export default function Demo() {
  return (
    <div className="space-y-8">
      <Sample name="command" title="beside a command">
        <Code compact lang="sh" className="max-w-xl">
          npx shadcn add @ag/copy
        </Code>
      </Sample>
      <Sample name="label" glance title="in a panel header">
        <div className="max-w-xl overflow-hidden rounded-xl border border-foreground/10">
          <div className="flex items-center justify-between gap-4 border-b border-foreground/10 bg-foreground/4 py-2 pl-4 pr-2">
            <span className="font-mono text-xs text-muted-foreground">
              note.txt
            </span>
            <Copy value="a little context goes a long way" label />
          </div>
          <p className="p-4 text-sm leading-relaxed text-foreground/80">
            a little context goes a long way
          </p>
        </div>
      </Sample>
      <Sample name="live" title="copy the current value">
        <CopyLiveExample />
      </Sample>
    </div>
  )
}

the source, every file an install writes

copy.tsx
"use client"

// Copy to the clipboard, with the only feedback that matters: the control says it
// worked, in place, and goes back on its own. No toast, nothing to dismiss.

import { Check, CircleAlert, Clipboard } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"

/** How long the control admits it copied. Long enough to read at a glance, short
 *  enough that a second copy is never blocked by the first one's applause. */
export const COPIED_MS = 1400

/** `copied` flips true on a successful write and clears itself. The timer is cleared
 *  on unmount and on every new copy, so a fast second press restarts it rather than
 *  being cut short by the first one's expiry. */
export function useCopy(text: string | (() => string)) {
  const [copied, setCopied] = React.useState(false)
  const [error, setError] = React.useState<unknown>(null)
  const timer = React.useRef(0)
  React.useEffect(() => () => window.clearTimeout(timer.current), [])
  const copy = React.useCallback(async () => {
    const value = typeof text === "function" ? text() : text
    // Empty is not a copy: writing "" silently wipes what the reader already had.
    window.clearTimeout(timer.current)
    setCopied(false)
    setError(null)
    if (!value) return false
    try {
      await navigator.clipboard.writeText(value)
      setCopied(true)
      timer.current = window.setTimeout(() => setCopied(false), COPIED_MS)
      return true
    } catch (cause) {
      setError(cause)
      return false
    }
  }, [text])
  return { copied, copy, error }
}

export interface CopyProps
  extends Omit<React.ComponentProps<"button">, "children" | "value"> {
  /** The text to write. A function is read at press time, for a live value. */
  value: string | (() => string)
  /** Show the word beside the mark. Off in tight corners, where the mark is enough. */
  label?: boolean
}

export function Copy({
  value,
  label = false,
  className,
  onClick,
  ...props
}: CopyProps) {
  const { copied, copy, error } = useCopy(value)
  return (
    <button
      data-slot="copy"
      type="button"
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) void copy()
      }}
      // The accessible name carries the state, because the icon swap alone is silent
      // to a screen reader and `aria-live` on a control this small is noise.
      aria-label={error ? "Copy failed. Try again" : copied ? "Copied" : "Copy"}
      title={
        error
          ? "Clipboard unavailable. Try again or select the text to copy."
          : undefined
      }
      data-copied={copied ? "" : undefined}
      className={cn(
        "inline-flex h-8 shrink-0 cursor-pointer select-none items-center justify-center gap-1.5 rounded-md px-2 font-mono text-xs leading-none text-muted-foreground transition-colors hover:bg-foreground/6 hover:text-foreground disabled:pointer-events-none disabled:opacity-40 data-copied:text-foreground",
        !label && "w-8 px-0",
        className,
      )}
      {...props}
    >
      {error ? (
        <CircleAlert size={14} aria-hidden />
      ) : copied ? (
        <Check size={14} aria-hidden />
      ) : (
        <Clipboard size={14} aria-hidden />
      )}
      {label && (
        <span
          data-slot="copy-label"
          className="inline-block min-w-[6ch] text-left"
        >
          {error ? "retry" : copied ? "copied" : "copy"}
        </span>
      )}
    </button>
  )
}