useCopyToClipboard
Copies text to the clipboard and reports a timed confirmation flag for UI feedback.
Falls back to a hidden textarea + document.execCommand("copy") when the Clipboard API is unavailable.
Install
$
npx shadcn@latest add @vibra/use-copy-to-clipboardNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import { CheckIcon, CopyIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
const SAMPLE = "npx shadcn@latest add @vibra/format"
export default function UseCopyToClipboardDemo() {
const { copied, copy } = useCopyToClipboard()
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Install command</CardTitle>
<CardDescription>Copies a sample string to the clipboard.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-2 rounded-md border bg-surface py-1 pr-1 pl-3">
<code className="truncate font-mono text-xs text-foreground">{SAMPLE}</code>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => copy(SAMPLE)}
aria-label={copied ? "Copied" : "Copy to clipboard"}
>
{copied ? <CheckIcon className="text-success" /> : <CopyIcon />}
</Button>
</div>
</CardContent>
</Card>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| timeout | number | 1500 | Milliseconds before copied resets to false. |
| returns.copied | boolean | — | True for timeout ms after a successful copy. |
| returns.copy | (text: string) => Promise<boolean> | — | Copies text; resolves true on success, false if every copy strategy fails. |
Dependencies
Registry
Source
import * as React from "react"
/** Writes `text` to the clipboard: the async Clipboard API first, falling back to a hidden textarea + execCommand. */
async function writeToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied or unavailable (insecure origin) — fall through to the legacy path.
}
}
if (typeof document === "undefined") return false
let textarea: HTMLTextAreaElement | undefined
try {
textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
return document.execCommand("copy")
} catch {
return false
} finally {
// Optional-chained: `textarea` is still undefined if `createElement`
// itself is what threw. Otherwise `remove()` is a no-op if `appendChild`
// above never ran, so this is safe to call unconditionally — the
// textarea must never linger in the DOM, whatever failed or succeeded.
textarea?.remove()
}
}
/** Copies text to the clipboard and reports a timed `copied` flag for UI feedback (e.g. swapping a copy icon for a checkmark). */
export function useCopyToClipboard(timeout = 1500): {
copied: boolean
copy: (text: string) => Promise<boolean>
} {
const [copied, setCopied] = React.useState(false)
const timeoutRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
const copy = React.useCallback(
async (text: string) => {
const ok = await writeToClipboard(text)
setCopied(ok)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
if (ok) timeoutRef.current = setTimeout(() => setCopied(false), timeout)
return ok
},
[timeout]
)
return { copied, copy }
}