Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
valuestringThe text shown and copied.
labelstringOptional label above the field.
secretbooleanfalseMask the value with dots until revealed.
copyLabelstring"Copy"Accessible name for the copy button.
revealLabelstring"Show value"Accessible name for the reveal toggle while hidden.
timeoutnumber2000How 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