Installation
npx shadcn@latest add @pixeldosa/smart-fieldvalue/onValueChange are required — this is a controlled component only, matching ghost-input's contract. fetchProposal must resolve to the FULL proposed value (not a delta) plus optional confidence/provenance; return null/undefined for 'no proposal available' rather than throwing. triggerIcon is required with no bundled default — pass any size-4 SVG/icon element. autoPropose only fires once, on mount, and only when value is empty, so it can never silently overwrite existing data. Drops into <FieldControl> directly since id/aria-invalid/aria-describedby/ref all forward to the underlying <input>.
Usage
"use client";
import * as React from "react";
import { SmartField, type SmartFieldProposal } from "@/components/ui/smart-field";
async function fetchProposal(
currentValue: string,
signal: AbortSignal
): Promise<SmartFieldProposal | null> {
const response = await fetch(`/api/enrich?value=${currentValue}`, { signal });
if (!response.ok) return null;
return response.json(); // { value, confidence, provenance }
}
export function CompanyField() {
const [value, setValue] = React.useState("");
return (
<SmartField
id="company"
value={value}
onValueChange={setValue}
fetchProposal={fetchProposal}
triggerIcon={<SparkleIcon />}
triggerLabel="Suggest company name"
/>
);
}
The field's value never changes until the proposal is explicitly accepted. Typing manually at any point — while a fetch is in flight, or once a proposal is shown but not yet accepted — cancels the request or dismisses the proposal, since manual input always wins over an AI suggestion.
Confidence and provenance
return {
value: "Acme Robotics, Inc.",
confidence: "medium",
provenance: "acme-robotics.com",
};
confidence is a three-tier enum (low / medium / high), not a raw percentage —
a fabricated number would claim precision the model doesn't actually have. provenance
is free text rendered as via {provenance}; omit it if you have nothing honest to show.
Auto-propose on an empty field
<SmartField autoPropose value={value} onValueChange={setValue} fetchProposal={fetchProposal} triggerIcon={<SparkleIcon />} />
Fires once, on mount, and only when value is empty — it can never silently
reconsider a value that's already there. Off by default; most fields should wait for an
explicit click.
Features
- Nothing commits silently. The field's value never changes until the proposal is explicitly accepted, and a one-step Undo stays available afterwards (DESIGN.md §3.7).
- Manual input always wins. Typing at any point — mid-fetch, or with a proposal on screen — cancels the request or dismisses the proposal, so the AI can never overwrite what someone is actively doing.
- Confidence as a three-tier enum, not a percentage, shown as text with a supplementary bar rather than a colour-coded badge.
- Provenance next to the proposal, so a value arrives with its evidence instead of asking to be trusted on its own.
- Auto-propose is opt-in and fires once, only on mount and only into an empty field — it can never silently reconsider a value that's already there.
- Drops into
<FieldControl>unchanged, forwardingid,aria-invalidandaria-describedbyto the real<input>.
Examples
Auto-proposing into an empty field, for a form where the enrichment is the whole point and waiting for a click is pure friction:
<SmartField
autoPropose
value={value}
onValueChange={setValue}
fetchProposal={fetchProposal}
triggerIcon={<SparkleIcon />}
/>
Reacting to an acceptance — the honest place to measure whether proposals are actually good, rather than counting how often they were merely shown:
<SmartField
value={value}
onValueChange={setValue}
fetchProposal={fetchProposal}
triggerIcon={<SparkleIcon />}
onAcceptProposal={(value, proposal) =>
analytics.track("proposal_accepted", { confidence: proposal.confidence })
}
/>
Returning null is a first-class answer — say nothing rather than propose noise:
async function fetchProposal(currentValue, signal) {
const match = await lookup(currentValue, signal);
if (!match || match.score < 0.6) return null;
return { value: match.name, confidence: match.tier, provenance: match.domain };
}
Composed inside a Field, where label, description and error wiring come for free:
<Field invalid={Boolean(error)}>
<FieldLabel>Company</FieldLabel>
<FieldControl>
<SmartField
value={value}
onValueChange={setValue}
fetchProposal={fetchProposal}
triggerIcon={<SparkleIcon />}
/>
</FieldControl>
<FieldError>{error}</FieldError>
</Field>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Controlled field value. |
onValueChange | (value: string) => void | — | Required. |
fetchProposal | (currentValue: string, signal: AbortSignal) => Promise<SmartFieldProposal | null | undefined> | — | Required. Return null/undefined for "no proposal." |
onAcceptProposal | (value: string, proposal: SmartFieldProposal) => void | — | Fires after the user accepts. |
triggerIcon | React.ReactNode | — | Required. No bundled icon set — pass a size-4 icon. |
triggerLabel | string | "Suggest a value with AI" | Accessible label for the trigger button. |
autoPropose | boolean | false | Fetch once on mount if value is empty. |
undoWindowMs | number | 6000 | How long the Undo affordance stays visible after accepting. |
SmartFieldProposal is { value: string; confidence?: "low" | "medium" | "high"; provenance?: string }.
Accessibility
- Every state change (proposal arrived, accepted, undone) is announced via
aria-live="polite". - Confidence is communicated as text, never colour alone — the tiered bar indicator next to the label is supplementary, not the only signal.
- Trigger, Accept, Reject and Undo are all real
<button>elements, keyboard-reachable by default, with a minimum 44×44px hit target. - Drops into
<FieldControl>directly —id,aria-invalidandaria-describedbyall forward to the underlying<input>. - Under
prefers-reduced-motion, the proposal panel's expand/collapse and every colour transition lose their animation but not the underlying state change.
When not to use
Inline predictive continuation while typing is Ghost Input, not this — Smart Field
proposes a complete replacement value reviewed before commit, not a tail appended as
you type. Multi-field or long-text diffing belongs to Diff Accept; a consequential or
risky action needing full review belongs to AI Approval Gate.
why it's built this way
Engineering Notes
Mirrors ghost-input's contract shape (value/onValueChange controlled-only, a fetchX behaviour injected by the consumer, an AbortSignal for cancellation) deliberately, so the AI tier reads as one consistent family rather than each component inventing its own pattern — the two differ only in what they return: a plain continuation string for ghost-input versus a structured { value, confidence, provenance } proposal here, because a complete replacement value needs that context attached and a continuation tail does not. Confidence rendering composes the shared `confidence-meter` primitive (extracted after Selection Actions needed the identical tiered indicator) rather than hand-rolling its own bars a second time — a three-tier enum (low/medium/high), not a raw number, because a fabricated percentage would claim precision the underlying model doesn't actually have, and because the system has no colour scale to spend on a red/yellow/green treatment anyway. autoPropose is opt-in and gated to empty fields only, adopted directly from CRM-enrichment research (Clay/HubSpot/Attio): confidence gates whether a proposal is allowed to overwrite existing data, so the safest default auto-behaviour is 'only ever fill a blank,' never 'silently reconsider what's already there.' Manual typing at any point cancels an in-flight fetch or dismisses a shown-but-unaccepted proposal, reusing ghost-input's staleness philosophy: a suggestion answering a question the field no longer asks is worse than no suggestion. The proposal panel animates open/closed via a CSS grid-template-rows trick (0fr to 1fr) rather than measuring and animating an explicit pixel height, which is the standard token-free way to animate a height that depends on variable content without a ResizeObserver.
how it moves, and when it doesn't
Motion Notes
The proposal panel's height expansion and the field's colour tint both use --pd-duration-fast/--pd-ease-standard; the trigger's busy spinner and accept/reject colour changes use --pd-duration-instant, matching Button's press-feedback timing, because those are immediate state confirmations rather than content arriving. The proposed value itself never animates in character-by-character — the full value already exists the moment it's returned, so a typewriter reveal would misrepresent it as being generated live, the same reasoning ghost-input's plan already established. Confidence Meter (composed, not reimplemented here) has no motion of its own — under prefers-reduced-motion, the panel's grid-template-rows transition and the colour transitions are dropped via motion-reduce:transition-none, leaving the underlying state (panel present/absent, tint present/absent) fully legible without animation.