Copy field
A read-only mono value in a muted field with a copy button and an optional mask.
npx shadcn@latest add https://ui.goodtabs.co/r/copy-field.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/copy-field
Dots until revealed. Copy writes the full key either way.
A URL
A plain value with a copy button. Focus selects the whole value.
With labels
Two fields with labels above, one of them a secret.
Usage
import { CopyField } from "@/components/ui/copy-field"
<CopyField label="API key" value={apiKey} secret copyLabel="Copy API key" />
<CopyField value="https://ui.goodtabs.co" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | The text shown and copied. | |
| label | string | Optional label above the field. | |
| secret | boolean | false | Mask the value with dots until revealed. |
| copyLabel | string | "Copy" | Accessible name for the copy button. |
| revealLabel | string | "Show value" | Accessible name for the reveal toggle while hidden. |
| timeout | number | 2000 | How long the check mark stays, in ms. |
Source
copy-field/copy-field.tsx
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type CopyFieldProps = Omit<React.ComponentProps<"div">, "children"> & {
/** The text shown and copied. */
value: string;
/** Optional label above the field. */
label?: string;
/** Mask the value with dots until revealed. */
secret?: boolean;
/** Accessible name for the copy button. */
copyLabel?: string;
/** Accessible name for the reveal toggle while hidden. */
revealLabel?: string;
/** How long the check mark stays, in ms. */
timeout?: number;
};
/**
* A read-only value in a muted, mono field with a copy button on the right.
* Secrets stay masked until revealed; copying always writes the full value.
*/
export function CopyField({
value,
label,
secret = false,
copyLabel = "Copy",
revealLabel = "Show value",
timeout = 2000,
className,
...props
}: CopyFieldProps) {
const id = React.useId();
const [shown, setShown] = React.useState(!secret);
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(() => () => clearTimeout(timer.current), []);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), timeout);
} catch {
// Clipboard can be blocked (insecure context, permissions). The value is still visible or revealable.
}
};
const CopyIconState = copied ? CheckIcon : CopyIcon;
return (
<div data-slot="copy-field" className={cn("flex w-full min-w-0 flex-col gap-1.5", className)} {...props}>
{label ? (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
) : null}
<div className="flex h-8 min-w-0 items-center gap-0.5 rounded-md border bg-muted pr-0.5 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50">
<Input
id={id}
readOnly
type={shown ? "text" : "password"}
value={value}
aria-label={label ? undefined : "Value to copy"}
onFocus={(e) => e.currentTarget.select()}
className="h-full flex-1 rounded-none border-0 bg-transparent px-2.5 font-mono text-sm text-foreground shadow-none focus-visible:border-transparent focus-visible:ring-0 md:text-sm dark:bg-transparent"
/>
{secret && (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-pressed={shown}
aria-label={shown ? "Hide value" : revealLabel}
onClick={() => setShown((s) => !s)}
className="shrink-0 text-foreground/70 hover:text-foreground"
>
{shown ? <EyeOffIcon aria-hidden="true" /> : <EyeIcon aria-hidden="true" />}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={copied ? "Copied" : copyLabel}
data-copied={copied || undefined}
onClick={copy}
className="shrink-0 text-foreground/70 transition-colors hover:text-foreground"
>
<CopyIconState aria-hidden="true" />
</Button>
<span className="sr-only" aria-live="polite">
{copied ? "Copied" : ""}
</span>
</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/copy-field/prompt.md, or the Copy AI prompt button above.
When to use
- API keys, tokens, webhook URLs and ids in settings pages
- Install commands and share links that people copy more than they read
- Anywhere a value is read only but should still be selectable
When not to use
- Editable values, use an input with a copy button next to it
- Multi-line content such as a config file, use a code block
- When the value must never be shown, keep it server side and offer a rotate action instead
Agent notes
- No functions are taken, so server components can render it; the file itself is a client component
- Secrets start masked and the reveal toggle has aria-pressed; copy always writes the full value
- The input is readOnly and focus selects it, so people can also copy by hand
- Clipboard writes need a secure context (https or localhost); failures are silent by design