Installation
npx shadcn@latest add @pixeldosa/ghost-inputvalue/onValueChange are required — this is a controlled component only. fetchSuggestion must resolve to the FULL continued string (current value + completion), not just the completion tail; the component slices the tail itself and drops any result that doesn't start with the value it was requested for, so a response arriving after the user has kept typing can't overwrite what they see. Drops into <FieldControl> directly since id/aria-invalid/aria-describedby/ref all forward to the underlying <input>.
Usage
import { GhostInput } from "@/components/ui/ghost-input";
async function fetchSuggestion(value: string, signal: AbortSignal) {
const res = await fetch("/api/complete", {
method: "POST",
body: JSON.stringify({ prompt: value }),
signal,
});
const { completion } = await res.json();
// Must return the FULL string — current value plus the completion.
return value + completion;
}
export function ReplyBox() {
const [value, setValue] = useState("");
return (
<GhostInput
value={value}
onValueChange={setValue}
fetchSuggestion={fetchSuggestion}
placeholder="Write a reply…"
/>
);
}
Inside a Field, drop it straight into FieldControl — id and ARIA wiring pass through:
<Field>
<FieldLabel>Reply</FieldLabel>
<FieldControl>
<GhostInput value={value} onValueChange={setValue} fetchSuggestion={fetchSuggestion} />
</FieldControl>
</Field>
Features
- Race-proof suggestions: every keystroke aborts the in-flight request before starting a new debounce, and any response that doesn't extend the current value is discarded — a slow reply can never overwrite what the user is now looking at.
- Nothing is ever inserted for you. Tab (or → at the end of the text) accepts, Esc dismisses. There is no auto-accept timer, by design.
- A real
<input>, not a contentEditable — IME composition, autofill, native undo and screen-reader field semantics all behave exactly as they would in a plain input. - Announced without double-reading: the ghost text is
aria-hiddenand anaria-liveregion announces the suggestion plus its accept/dismiss keys instead. - Drops into
Fieldunchanged, with id and ARIA wiring passing straight through.
Examples
Tuning when a suggestion is requested — wait longer after typing stops, and don't ask at all until there's enough context to complete:
<GhostInput
value={value}
onValueChange={setValue}
fetchSuggestion={fetchSuggestion}
debounceMs={500}
minChars={12}
/>
Reacting to an accepted completion — useful for logging acceptance rate, which is the honest measure of whether the suggestions are any good:
<GhostInput
value={value}
onValueChange={setValue}
fetchSuggestion={fetchSuggestion}
onAcceptSuggestion={(full) => analytics.track("ghost_accepted", { length: full.length })}
/>
The live example at the top of this page is fully interactive — type a few words and pause to see the completion appear.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. This is a controlled component only. |
onValueChange | (value: string) => void | — | Required. |
fetchSuggestion | (value: string, signal: AbortSignal) => Promise<string | null | undefined> | — | Required. Must resolve to the full continued string, not just the tail. |
debounceMs | number | 300 | Delay after the user stops typing before requesting a suggestion. |
minChars | number | 1 | Minimum trimmed length before a suggestion is requested. |
onAcceptSuggestion | (value: string) => void | — | Fired with the full accepted value. |
All other props forward to the underlying <input>.
Behaviour
- Every keystroke cancels the in-flight request via
AbortSignalbefore starting a new debounce timer — there is no scenario where a stale response can land after a newer one. - A response that doesn't start with the value it was requested for is discarded, so a slow response can't overwrite what the user is now looking at.
- Tab, or → with the caret already at the end of the text, accepts. Esc dismisses without touching the typed value. Any other key just continues typing normally.
- The suggestion is never inserted automatically — there is no "auto-accept after N ms" mode.
Accessibility
- Implemented as a real
<input>with a visually-hidden mirror layer, not a contentEditable — IME composition, autofill, undo and screen-reader field semantics all behave exactly like a plain text input. - When a suggestion is available, an
aria-live="polite"region announces it and the accept/dismiss keys, since the ghost text itself isaria-hidden. - Right-arrow acceptance only triggers with the caret at the end of the text, so normal in-text cursor navigation is never intercepted.
why it's built this way
Engineering Notes
The ghost text is rendered as a separate mirror div positioned exactly behind the real <input>, with the typed-text portion set to transparent — not as a styled fake caret or a contentEditable trick. This is the only approach that keeps native input behaviour (IME composition, selection, undo, autofill, screen-reader field semantics) completely intact, at the cost of the mirror div needing identical padding/font/line-height to the input, which is why both come from the same className. The whole request lifecycle — debounce timer, AbortController — lives in refs rather than state so that a rapid keystroke can synchronously cancel the previous request without waiting for a re-render; a state-based cancellation would let a slow response race a fast one and briefly show a stale suggestion. The right-arrow accept only fires when the caret is already at the end of the value (checked via selectionStart/selectionEnd), because otherwise it would silently hijack normal in-text cursor movement, which is a worse failure mode than requiring Tab.
how it moves, and when it doesn't
Motion Notes
The suggestion's appearance is a plain opacity transition using duration.fast and easing.standard from the token scale — deliberately not a slide or typewriter reveal, both of which would misrepresent what happened: the whole continuation exists immediately (it was already fetched), so animating it in character-by-character would be a fabricated sense of the model 'typing', not a real progressive state. Under prefers-reduced-motion this reduces to a duration.instant swap via the same token path every other component in the system uses; nothing here required a component-specific reduced-motion branch, which is itself a signal the animation is doing the minimum necessary rather than something decorative.