PixelDosa UI

Ghost Input

A single-line input with inline AI ghost-text completion — pause, see a dimmed continuation, accept with Tab or dismiss with Escape.

Try "Thank you for your", "Following up", or "I wanted to check" — pause, then press Tab.

Installation

bash
npx shadcn@latest add @pixeldosa/ghost-input

value/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-hidden and an aria-live region announces the suggestion plus its accept/dismiss keys instead.
  • Drops into Field unchanged, 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

PropTypeDefaultDescription
valuestring—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.
debounceMsnumber300Delay after the user stops typing before requesting a suggestion.
minCharsnumber1Minimum 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 AbortSignal before 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 is aria-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.