Copy Field
A read-only value with a copy button, and optional masking for secrets. Built on the shadcn Button and the useCopy hook.
import { CopyField } from '@/components/custom/copy-field'
export default function CopyFieldDemo() {
return (
<div className="w-full max-w-md">
<CopyField label="Payment reference" value="KP-9F2A41B7-2026" />
</div>
)
}Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/copy-field.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/copy-field.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/copy-field.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/copy-field.jsonThis item installs three files — the component, the use-copy hook it depends on, and kiota-theme — plus the shadcn button primitive. It is the clearest example in this registry of one item pulling in a whole small tree.
Usage
import { CopyField } from '@/components/custom/copy-field'
export function ReferenceRow() {
return <CopyField label="Payment reference" value="KP-9F2A41B7-2026" />
}Props
Extends React.ComponentProps<'div'> minus onCopy.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The full value. Always copied in full, even while masked. |
label | ReactNode | — | Optional caption above the field. |
masked | boolean | false | Hide the value behind dots until revealed. |
visibleChars | number | 4 | Trailing characters left readable while masked. |
onCopied | (success: boolean) => void | — | Called after a copy attempt with the result. |
Examples
Masked secrets
Reveal is a toggle, not a one-way door — the value can be hidden again without a re-render of the surrounding page.
import { CopyField } from '@/components/custom/copy-field'
export default function CopyFieldMasked() {
return (
<div className="w-full max-w-md space-y-4">
<CopyField label="Live secret key" value="sk_live_9f2a41b7c3d5e6f8a0b2c4d6" masked />
<CopyField label="Webhook signing secret" value="whsec_2f8a91c04be7d3a6" masked visibleChars={6} />
</div>
)
}Behaviour notes
Copy always writes the real value. Masking is a shoulder-surfing guard, not access control. Requiring a reveal before copying would add friction without adding security, and mostly trains people to leave secrets visible on screen.
The clipboard write has a fallback. navigator.clipboard is missing on insecure origins and rejects when the document is unfocused. useCopy tries the async API first and falls through to document.execCommand whenever it fails, rather than only when it is absent.
Copy can still fail. If both paths fail, onCopied receives false and no checkmark appears — the confirmation never claims success it did not get.
<CopyField
value={apiKey}
masked
onCopied={(success) => {
if (!success) toast.error('Could not access the clipboard')
}}
/>Accessibility
The masked display is visual only — screen readers get the real value through aria-label, since dots convey nothing useful when read aloud. Copy confirmation is announced through an aria-live="polite" region, so keyboard and screen-reader users get the same feedback the checkmark gives sighted users. The reveal toggle carries aria-pressed to expose its state.
This component renders whatever string it is handed. Masking does not prevent the value reaching the browser — if a secret should not be readable by the client at all, do not send it to the client.