KKiotaPay UI
Browse documentation

useCopy

Clipboard write with a self-resetting copied flag, an execCommand fallback for insecure origins, and cleanup on unmount.

Installation

pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/use-copy.json

Installs hooks/use-copy.ts. It has no dependencies of its own, and is pulled in automatically by copy-field.

Usage

'use client'

import { useCopy } from '@/hooks/use-copy'

export function CopyReference({ reference }: { reference: string }) {
  const { copied, copy } = useCopy()

  return (
    <button type="button" onClick={() => void copy(reference)}>
      {copied ? 'Copied' : 'Copy reference'}
    </button>
  )
}

API

function useCopy(options?: { timeout?: number }): {
  copied: boolean
  copy: (text: string) => Promise<boolean>
  reset: () => void
}
FieldTypeDescription
options.timeoutnumberHow long copied stays true, in milliseconds. Defaults to 2000.
copiedbooleantrue between a successful copy and the timeout.
copy(text: string) => Promise<boolean>Writes to the clipboard. Resolves to whether it succeeded.
reset() => voidClears copied and cancels the pending timeout.

Behaviour notes

copy resolves rather than throwing. A rejected clipboard write is an ordinary outcome — permissions policy, an insecure origin, a hostile embed — not an exceptional one, so the result is a boolean you can branch on.

const ok = await copy(value)
if (!ok) toast.error('Could not access the clipboard')

The fallback is keyed on failure, not on absence. navigator.clipboard is undefined outside secure contexts — any staging environment on plain HTTP — but it also exists and then rejects, with NotAllowedError, when the document is not focused or a permissions policy forbids the write. The second case is the more common one, and an if (clipboard) … else legacy() shape gives up on it entirely. The hook instead tries the async API, and falls through to a temporary off-screen <textarea> with document.execCommand('copy') whenever that path fails for any reason.

That textarea is positioned off-screen rather than hidden with display: none, because an unrendered element cannot be selected and execCommand would quietly copy nothing.

The timeout is cleaned up on unmount. Copying and then navigating away inside the timeout window would otherwise leave a setState pointed at a dead component.

Announce the result rather than relying on a visual state change alone. copy-field wraps its confirmation in an aria-live="polite" region — worth copying if you build your own trigger.