f-ui
Components

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:

JobUse this shellDo not use
Copy a read field / ID next to its valueCopyAffordance / Copyable / FieldConfig.copyableMessage action APIs
Copy a whole assistant (or user) message from the turn toolbarMessage Copy ActionCopyAffordance wrapping the markdown
Copy fenced code / diagram source from a content toolbarCode Block / Mermaid Renderer toolbar (via the shared hook)Ad-hoc navigator.clipboard in the part
  • Use CopyAffordance for ad-hoc IDs, install commands, and free-form layouts outside schema cells.
  • Prefer schema copyable: true on 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

ShellChromeBehavior source
FieldHover / always-visible side button next to the valueCopyAffordanceuseClipboardCopy
MessageGhost icon in the turn action rowMessageCopyActionuseClipboardCopy
Code / diagramToolbar copy on Code Block / MermaidPart toolbar → useClipboardCopy

Visual chrome stays different on purpose — only the clipboard + copied timer is shared.

Features

AreaBehavior
Visibilityhover (default) or always for the side button
ClipboarduseClipboardCopy — success flashes Check ~2s; empty text is a no-op
LabelscopyLabel / copiedLabel on the affordance button
SchemaFieldConfig.copyable mounts the same affordance via renderCellByKind

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.json
npx shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.json
bun x shadcn@latest add https://ui.isaacfei.com/r/copy-affordance.json

With 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…7e0f

Always 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 + useClipboardCopy

API Reference

Props

PropTypeDefaultDescription
textstringClipboard payload.
childrenReactNodeDisplay value beside the button.
visibility"hover" | "always""hover"When the copy button is visible.
showCopybooleantrueWhen false, hides the button.
onCopy() => voidFires after a successful write.
copyLabel / copiedLabelstring"Copy" / "Copied"Button accessible name.
classNamestringMerged onto the root.
classNames{ root?, button? }Per-slot class overrides.

Slots

SlotElement
rootOuter span
buttonCopy control

Hook

useClipboardCopy(options?) returns { copied, copy, reset }.

OptionTypeDefaultDescription
textstringDefault payload for copy().
resetMsnumber2000How long copied stays true.
onCopy(text: string) => voidAfter a successful write.
onError(error: unknown) => voidClipboard 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.

On this page