Copy Affordance
Field-side copy chrome and the shared useClipboardCopy hook for every kit copy shell.
Copy Affordance is the field / inline value copy shell — a value plus a side copy button. The same registry item also exports useClipboardCopy, the shared clipboard state machine used by Message Actions, Code Block, Mermaid, and this affordance. Choose the shell by job; never fork the timer/clipboard logic again.
When To Use
One behavior, many shells. All kit copy buttons that write a string to the clipboard must use useClipboardCopy (or a thin wrapper that does). Pick the shell from the job:
| Job | Use this shell | Do not use |
|---|---|---|
| Copy a read field / ID next to its value | CopyAffordance / Copyable / FieldConfig.copyable | Message action APIs |
| Copy a whole assistant (or user) message from the turn toolbar | Message Copy Action | CopyAffordance wrapping the markdown |
| Copy fenced code / diagram source from a content toolbar | Code Block / Mermaid Renderer toolbar (via the shared hook) | Ad-hoc navigator.clipboard in the part |
- Use
CopyAffordancefor ad-hoc IDs, install commands, and free-form layouts outside schema cells. - Prefer schema
copyable: trueon Field Types for list/detail cells. - Prefer Copyable when you need truncated mono + optional link as a product chip.
- Use the headless hook when you own the chrome entirely.
Shell Notes
| Shell | Chrome | Behavior source |
|---|---|---|
| Field | Hover / always-visible side button next to the value | CopyAffordance → useClipboardCopy |
| Message | Ghost icon in the turn action row | MessageCopyAction → useClipboardCopy |
| Code / diagram | Toolbar copy on Code Block / Mermaid | Part toolbar → useClipboardCopy |
Visual chrome stays different on purpose — only the clipboard + copied timer is shared.
Features
| Area | Behavior |
|---|---|
| Visibility | hover (default) or always for the side button |
| Clipboard | useClipboardCopy — success flashes Check ~2s; empty text is a no-op |
| Labels | copyLabel / copiedLabel on the affordance button |
| Schema | FieldConfig.copyable mounts the same affordance via renderCellByKind |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.jsonWith a namespace: npx shadcn@latest add @f-ui/copy-affordance.
registryDependencies: none. Runtime npm: lucide-react.
Usage
import { CopyAffordance } from "@/components/f-ui/copy-affordance/copy-affordance";
<CopyAffordance text={workflowId}>
<code>{truncatedId}</code>
</CopyAffordance>Examples
Field Shell
Compose CopyAffordance around your own truncated display. Hover the first row for the side button; the second keeps copy always visible.
Hover for copy
wf_7c2a9…7e0fAlways visible
wf_7c2a9f1…4c7e0f"use client";
import { CopyAffordance } from "@/components/f-ui/copy-affordance/copy-affordance";
import { formatTruncatedText } from "@/components/f-ui/copy-affordance/format-truncated-text";
const WORKFLOW_ID = "wf_7c2a9f1e4b8d3a0c5e6f9a2b1d4c7e0f";
/**
* Ad-hoc ID layout: compose CopyAffordance with your own display
* (truncate helper + optional mono). Not a product chip component.
*/
export function CopyAffordanceDemo() {
return (
<div className="flex flex-col gap-4 text-sm">
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium">Hover for copy</p>
<CopyAffordance text={WORKFLOW_ID}>
<code className="bg-muted/60 rounded px-1.5 py-0.5 font-mono text-xs">
{formatTruncatedText(WORKFLOW_ID, {
mode: "middle",
prefix: 8,
suffix: 4,
})}
</code>
</CopyAffordance>
</div>
<div className="space-y-1">
<p className="text-muted-foreground text-xs font-medium">Always visible</p>
<CopyAffordance text={WORKFLOW_ID} visibility="always">
<code className="bg-muted/60 rounded px-1.5 py-0.5 font-mono text-xs">
{formatTruncatedText(WORKFLOW_ID, { mode: "middle", prefix: 10, suffix: 6 })}
</code>
</CopyAffordance>
</div>
</div>
);
}Headless Usage
useClipboardCopy is the view-model: copied, copy(overrideText?), and reset. Spread onto native controls when you own the chrome — stock CopyAffordance / message / code shells are optional.
text: sku-7c2a-9f1e-4b8d
copied: false
status: idle
"use client";
import { useState } from "react";
import { useClipboardCopy } from "@/components/f-ui/copy-affordance/use-clipboard-copy";
const SAMPLE = "sku-7c2a-9f1e-4b8d";
/**
* Headless useClipboardCopy: native button + plain readout (no DS chrome).
*/
export function UseClipboardCopyHeadlessDemo() {
const [log, setLog] = useState<string>("idle");
const { copied, copy, reset } = useClipboardCopy({
text: SAMPLE,
onCopy: (text) => setLog(`copied: ${text}`),
onError: () => setLog("error"),
});
return (
<div>
<p>text: {SAMPLE}</p>
<p>copied: {String(copied)}</p>
<p>status: {log}</p>
<button
type="button"
onClick={() => {
void copy();
}}
>
{copied ? "Copied" : "Copy"}
</button>{" "}
<button type="button" onClick={reset}>
Reset
</button>
</div>
);
}Composition
CopyAffordance
├── children (host display value)
└── copy button ← useClipboardCopy
MessageCopyAction ← MessageAction chrome + useClipboardCopy
Code Block / Mermaid ← toolbar chrome + useClipboardCopyAPI Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | Clipboard payload. |
children | ReactNode | — | Display value beside the button. |
visibility | "hover" | "always" | "hover" | When the copy button is visible. |
showCopy | boolean | true | When false, hides the button. |
onCopy | () => void | — | Fires after a successful write. |
copyLabel / copiedLabel | string | "Copy" / "Copied" | Button accessible name. |
className | string | — | Merged onto the root. |
classNames | { root?, button? } | — | Per-slot class overrides. |
Slots
| Slot | Element |
|---|---|
root | Outer span |
button | Copy control |
Hook
useClipboardCopy(options?) returns { copied, copy, reset }.
| Option | Type | Default | Description |
|---|---|---|---|
text | string | — | Default payload for copy(). |
resetMs | number | 2000 | How long copied stays true. |
onCopy | (text: string) => void | — | After a successful write. |
onError | (error: unknown) => void | — | Clipboard failure or missing API. |
copy(overrideText?) resolves overrideText ?? options.text; empty / missing text returns false without throwing. Success sets copied and starts the reset timer; failure keeps copied false.