Forms
Copy Field
A real clipboard field for commands, links and tokens, with async feedback, optional masking and an honest manual fallback.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/copy-field.json'use client';
import { useId, useLayoutEffect, useRef, useState } from 'react';
const css = `.cf-root {
--cf-paper: #fffdf8; --cf-ink: #322e25; --cf-muted: #777064; --cf-line: #e5dfd3; --cf-brass: #806224; --cf-wash: #f2ead8; --cf-success: #386448; --cf-error: #a14332;
width: min(100%, 540px); color: var(--cf-ink); font: 13px/1.5 system-ui, sans-serif;
}
.cf-root *, .cf-root *::before, .cf-root *::after { box-sizing: border-box; }
.cf-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 8px; }
.cf-label { font-size: 12px; font-weight: 650; letter-spacing: .01em; }
.cf-kind { flex: none; color: var(--cf-muted); font: 9px/1.5 ui-monospace, monospace; text-transform: uppercase; letter-spacing: .12em; }
.cf-well { position: relative; display: flex; align-items: flex-start; gap: 8px; min-width: 0; padding: 7px 7px 7px 14px; border: 1px solid var(--cf-line); border-radius: 12px; background: var(--cf-paper); box-shadow: 0 2px 3px #33261005; transition: border-color 140ms, box-shadow 140ms; }
.cf-well:focus-within { border-color: var(--cf-brass); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-brass) 14%, transparent); }
.cf-value { display: block; width: 100%; min-width: 0; flex: 1; min-height: 36px; max-height: 180px; margin: 0; padding: 8px 0; resize: vertical; overflow: auto; border: 0; border-radius: 0; outline: none; background: transparent; color: var(--cf-ink); font: 12px/20px ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; scrollbar-width: thin; }
.cf-actions { display: flex; flex: none; gap: 4px; }
.cf-root button { font: inherit; cursor: pointer; }
.cf-copy { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 76px; height: 36px; padding: 0 12px; border: 1px solid var(--cf-line); border-radius: 8px; background: var(--cf-wash); color: var(--cf-brass); font-size: 11px !important; font-weight: 650 !important; transition: background 140ms; }
.cf-copy svg { flex: none; width: 13px; height: 13px; }
.cf-copy:hover { background: color-mix(in srgb, var(--cf-wash) 70%, var(--cf-paper)); }
.cf-copy[data-state="copied"] { color: var(--cf-success); }
.cf-reveal { display: grid; place-items: center; width: 34px; height: 36px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--cf-muted); }
.cf-reveal:hover { background: var(--cf-wash); color: var(--cf-brass); }
.cf-reveal svg { width: 16px; height: 16px; }
.cf-root button:focus-visible { outline: 2px solid var(--cf-brass); outline-offset: 3px; }
.cf-root button[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }
.cf-root[data-state="copying"] .cf-copy { cursor: progress; }
.cf-foot { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; min-height: 28px; padding: 7px 2px 0; }
.cf-status { min-width: 0; margin: 0; color: var(--cf-muted); font-size: 10px; overflow-wrap: anywhere; }
.cf-root[data-state="copied"] .cf-status { color: var(--cf-success); }
.cf-root[data-state="failed"] .cf-status { color: var(--cf-error); }
.cf-manual { border: 0; padding: 0; background: transparent; color: var(--cf-brass); font-size: 10px !important; text-decoration: underline; text-underline-offset: 3px; }
.cf-manual[hidden], .cf-reveal[hidden] { display: none; }
:root[data-theme="dark"] .cf-root { --cf-paper: #28261f; --cf-ink: #eee8d9; --cf-muted: #b4ac9d; --cf-line: #494439; --cf-brass: #d7b569; --cf-wash: #3b3323; --cf-success: #a1c8a8; --cf-error: #f6ac99; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .cf-root { --cf-paper: #28261f; --cf-ink: #eee8d9; --cf-muted: #b4ac9d; --cf-line: #494439; --cf-brass: #d7b569; --cf-wash: #3b3323; --cf-success: #a1c8a8; --cf-error: #f6ac99; } }
@media (max-width: 380px) { .cf-well { gap: 4px; padding-left: 10px; } .cf-copy { min-width: 58px; padding: 0 8px; } .cf-copy svg { display: none; } }
@media (prefers-reduced-motion: reduce) { .cf-well, .cf-copy { transition: none; } }
`;
type CopyState = 'idle' | 'copying' | 'copied' | 'failed' | 'selected';
export function CopyField({ value, label, kind = 'Plain text', hint = 'Ready to copy. Yours to keep.', mask = false, onCopy, onCopyError }: {
value: string;
label: string;
kind?: string;
hint?: string;
/** Conceals the display only. The original value is still available to page code. */
mask?: boolean;
/** Runs only after the clipboard confirms a successful write. */
onCopy?: (value: string) => void;
onCopyError?: (error: unknown) => void;
}) {
const id = useId();
const field = useRef<HTMLTextAreaElement>(null);
const request = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectAfterReveal = useRef(false);
const [feedback, setFeedback] = useState<{ value: string; state: CopyState }>({ value, state: 'idle' });
const [reveal, setReveal] = useState({ value, visible: false });
const visible = !mask || (reveal.value === value && reveal.visible);
const state = feedback.value === value ? feedback.state : 'idle';
const empty = value.length === 0;
const displayValue = visible ? value : '••••••••••••••••••••••••';
// Invalidate before paint, so a resolved promise cannot report an old value
// after a replacement value has committed to the DOM.
useLayoutEffect(() => () => {
request.current++;
selectAfterReveal.current = false;
if (timer.current !== null) clearTimeout(timer.current);
}, [value]);
useLayoutEffect(() => {
if (!selectAfterReveal.current || !visible) return;
selectAfterReveal.current = false;
field.current?.focus();
field.current?.select();
}, [visible]);
async function copy() {
if (state === 'copying' || empty) return;
const attempt = ++request.current;
if (timer.current !== null) clearTimeout(timer.current);
setFeedback({ value, state: 'copying' });
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(value);
} catch (error) {
if (attempt !== request.current) return;
setFeedback({ value, state: 'failed' });
onCopyError?.(error);
return;
}
if (attempt !== request.current) return;
setFeedback({ value, state: 'copied' });
timer.current = setTimeout(() => setFeedback({ value, state: 'idle' }), 2500);
onCopy?.(value);
}
function selectText() {
if (!visible) { selectAfterReveal.current = true; setReveal({ value, visible: true }); }
else { field.current?.focus(); field.current?.select(); }
setFeedback({ value, state: 'selected' });
}
const message = state === 'copying' ? 'Copying…' : state === 'copied' ? 'Copied to your clipboard.'
: state === 'failed' ? 'Clipboard unavailable. Select the text to copy manually.'
: state === 'selected' ? 'Text selected. Press Ctrl+C or ⌘C to copy.'
: empty ? 'No value to copy.' : hint;
return <>
<style>{css}</style>
<div className="cf-root" data-state={state}>
<div className="cf-heading"><label className="cf-label" htmlFor={`${id}-value`}>{label}</label><span className="cf-kind">{kind}</span></div>
<div className="cf-well">
<textarea ref={field} className="cf-value" id={`${id}-value`} value={displayValue} readOnly spellCheck={false} wrap="off"
rows={Math.min(5, visible ? Math.max(1, value.split('\n').length) : 1)} aria-describedby={`${id}-status`} />
<div className="cf-actions">
{mask && <button type="button" className="cf-reveal" aria-label={visible ? 'Hide value' : 'Show value'} aria-pressed={visible}
onClick={() => setReveal({ value, visible: !visible })}>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />{visible && <path d="m3 3 18 18" />}</svg>
</button>}
<button type="button" className="cf-copy" data-state={state} aria-label={`Copy ${label}`} aria-disabled={state === 'copying' || empty}
onClick={() => { void copy(); }}>
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{state === 'copied' ? <path d="m4 10 4 4 8-8" /> : <><rect x="7" y="7" width="10" height="10" rx="2" /><path d="M12 4V3H3v9h1" /></>}</svg>
<span>{state === 'copying' ? 'Copying' : state === 'copied' ? 'Copied' : 'Copy'}</span>
</button>
</div>
</div>
<div className="cf-foot"><p className="cf-status" id={`${id}-status`} role="status" aria-live="polite">{message}</p>
{(state === 'failed' || state === 'selected') && <button type="button" className="cf-manual" onClick={selectText}>{visible ? 'Select text' : 'Reveal & select'}</button>}
</div>
</div>
</>;
}