← All components

CopyField

Text to copy in one or more versions, with a check that blurs in after the copy.

Install

$ npm i mihirmodi-ui motion

The idea

An install command, and the same request written for an agent to act on — two versions of one thing, with a sliding tab between them. The prompt's dollar sign is drawn but never copied, because the one thing a copy button must not do is hand you a line that fails when pasted. The mark blurs into a check for a beat and back, the same swap every hover on this site uses.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

CopyField — usage10 lines
import { CopyField, toast } from "mihirmodi-ui";

<CopyField
  title="Install"
  tabs={[
    { id: "cmd", label: "Command", text: "npm i mihirmodi-ui motion", prompt: true },
    { id: "prompt", label: "Prompt", text: "Add mihirmodi-ui to this project…" },
  ]}
  onCopy={() => toast("Copied to clipboard")}
/>

Props

PropNotes
tabs{ id, label, text, prompt? }[]One is a plain field; two or more get tabs. `prompt` draws a `$` that is not copied.
titlestringA heading left of the tabs.
onCopy(text) => voidAfter a successful copy — a toast.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/inputs/CopyField.tsx122 lines
"use client";

import { useEffect, useRef, useState } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CheckIcon } from "../../lib/glyph.js";
import { FLAT, SLIDE } from "../../lib/motion.js";

export type CopyFieldTab = { id: string; label: string; text: string; /** Show a `$` before it, not copied. */ prompt?: boolean };

export type CopyFieldProps = {
  tabs: CopyFieldTab[];
  /** A heading over the field, left of the tabs. */
  title?: string;
  onCopy?: (text: string) => void;
  className?: string;
};

/**
 * Text to copy, in one or more versions — an install command and the same
 * request written for an agent, say — with tabs to pick between them.
 *
 * The tab highlight slides between versions, and the copy button's mark blurs
 * into a check for a beat after a copy, and back. The `$` of a command prompt is
 * drawn but not copied, because the one thing a copy button must not do is hand
 * you a line that fails when pasted.
 */
export function CopyField({ tabs, title, onCopy, className = "" }: CopyFieldProps) {
  const reduce = useReducedMotion();
  const [tab, setTab] = useState(tabs[0]?.id);
  const [copied, setCopied] = useState(false);
  const timer = useRef<number | undefined>(undefined);
  const current = tabs.find((t) => t.id === tab) ?? tabs[0];
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
  const idx = tabs.findIndex((t) => t.id === current?.id);

  useEffect(() => () => window.clearTimeout(timer.current), []);
  useEffect(() => {
    const el = tabRefs.current[idx];
    if (el) setPill({ x: el.offsetLeft, w: el.offsetWidth });
  }, [idx, tabs.length]);

  const copy = async () => {
    if (!current) return;
    try {
      await navigator.clipboard.writeText(current.text);
      setCopied(true);
      onCopy?.(current.text);
      window.clearTimeout(timer.current);
      timer.current = window.setTimeout(() => setCopied(false), 1600);
    } catch {
      // Refused — the text is selectable either way.
    }
  };

  const swap = "absolute inset-0 flex items-center justify-center transition-[opacity,filter] motion-reduce:transition-none";

  return (
    <LazyMotion features={domAnimation} strict>
      <div className={className}>
        {(title || tabs.length > 1) && (
          <div className="flex items-end justify-between gap-4 border-b-[0.5px] border-gray-alpha-400 pb-2.5">
            {title ? <h3 className="text-[12px] uppercase tracking-[0.08em] text-quiet">{title}</h3> : <span />}
            {tabs.length > 1 && (
              <div role="tablist" className="relative flex items-center gap-0.5">
                {pill && (
                  <m.span
                    aria-hidden
                    className="absolute inset-y-0 left-0 rounded-lg bg-gray-100"
                    initial={false}
                    animate={{ x: pill.x, width: pill.w }}
                    transition={reduce ? FLAT : SLIDE}
                  />
                )}
                {tabs.map((t, i) => (
                  <button
                    key={t.id}
                    ref={(el) => {
                      tabRefs.current[i] = el;
                    }}
                    type="button"
                    role="tab"
                    aria-selected={t.id === current?.id}
                    onClick={() => {
                      setTab(t.id);
                      setCopied(false);
                    }}
                    className={`relative rounded-lg px-2.5 py-1 text-[12px] transition-colors duration-150 ${t.id === current?.id ? "text-gray-1000" : "text-quiet hover:text-gray-1000"}`}
                  >
                    {t.label}
                  </button>
                ))}
              </div>
            )}
          </div>
        )}
        <div className={`${title || tabs.length > 1 ? "mt-3" : ""} flex items-start gap-3 rounded-xl border-[0.5px] border-gray-alpha-400 bg-background-200 py-3 pl-3.5 pr-2.5`}>
          <code className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[12.5px] leading-[1.6] text-gray-900">
            {current?.prompt && <span className="select-none text-faint">$ </span>}
            {current?.text}
          </code>
          <button
            type="button"
            onClick={copy}
            aria-label={copied ? "Copied" : "Copy to clipboard"}
            className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-quiet transition-colors duration-150 ease-geist hover:bg-gray-alpha-200 hover:text-gray-1000"
          >
            <span className={swap} style={{ opacity: copied ? 0 : 1, filter: copied ? "blur(4px)" : "blur(0px)", transitionDuration: copied ? "180ms" : "280ms", transitionDelay: copied ? "0ms" : "140ms" }}>
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="h-[15px] w-[15px]">
                <rect x="8.5" y="8.5" width="11" height="11" rx="2.4" />
                <path d="M15.5 8.5V6.4A2 2 0 0 0 13.5 4.4H6.4A2 2 0 0 0 4.4 6.4v7.1a2 2 0 0 0 2 2h2.1" />
              </svg>
            </span>
            <span className={swap} style={{ opacity: copied ? 1 : 0, filter: copied ? "blur(0px)" : "blur(4px)", transitionDuration: copied ? "280ms" : "180ms", transitionDelay: copied ? "140ms" : "0ms" }}>
              <CheckIcon className="h-[15px] w-[15px]" />
            </span>
          </button>
        </div>
      </div>
    </LazyMotion>
  );
}