Skip to content
Plinth

Forms

Copy Field

A real clipboard field for commands, links and tokens, with async feedback, optional masking and an honest manual fallback.

#clipboard#copy#command#token#accessible

Install

npx shadcn@latest add https://plinthui.com/r/copy-field.json
Open in v0
'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>
  </>;
}

More in Forms

Similar tags