CopyField
Text to copy in one or more versions, with a check that blurs in after the copy.
Install
$ npm i mihirmodi-ui motionThe 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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| tabs{ id, label, text, prompt? }[] | { id, label, text, prompt? }[] | One is a plain field; two or more get tabs. `prompt` draws a `$` that is not copied. |
| titlestring | string | A heading left of the tabs. |
| onCopy(text) => void | (text) => void | After 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.
"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>
);
}