PixelDosa
ai

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.

Overview
View code

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>

Props

PropTypeDefaultDescription
valuestringRequired. This is a controlled component only.
onValueChange(value: string) => voidRequired.
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) => voidFired 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.

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.

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.

Source

The exact file shadcn add writes into your project.

ghost-input.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface GhostInputProps
  extends Omit<React.ComponentPropsWithoutRef<"input">, "value" | "onChange" | "children"> {
  /** Current field value. GhostInput is controlled — there is no uncontrolled mode. */
  value: string;
  onValueChange: (value: string) => void;
  /**
   * Called after the user pauses typing, with an `AbortSignal` that fires the moment
   * another keystroke arrives. Must resolve to a full continuation of `value` — a
   * result that doesn't start with the current value is treated as stale and dropped.
   * Return `null`/`undefined`/`""` for "no suggestion".
   */
  fetchSuggestion: (value: string, signal: AbortSignal) => Promise<string | null | undefined>;
  /** Debounce before `fetchSuggestion` fires, in ms. */
  debounceMs?: number;
  /** Don't request a suggestion below this many characters. */
  minChars?: number;
  /** Fired with the full accepted value when the user accepts the suggestion. */
  onAcceptSuggestion?: (value: string) => void;
  wrapperClassName?: string;
}

/**
 * A single-line text input with inline ghost-text completion: type, pause, and a
 * muted continuation appears after the caret. Tab or → (only with the caret already at
 * the end of the text) accepts it; Esc dismisses it; any other keystroke cancels the
 * in-flight request and discards it. It is never inserted automatically — acceptance is
 * always an explicit keypress, per the ghost-text UX convention this component follows.
 *
 * Renders a real `<input>` — `ref`, `id`, `aria-invalid` and `aria-describedby` all
 * forward onto it, so this drops directly into `<FieldControl>`.
 */
const GhostInput = React.forwardRef<HTMLInputElement, GhostInputProps>(function GhostInput(
  {
    value,
    onValueChange,
    fetchSuggestion,
    debounceMs = 300,
    minChars = 1,
    onAcceptSuggestion,
    className,
    wrapperClassName,
    onKeyDown,
    onBlur,
    ...props
  },
  forwardedRef
) {
  const [suggestion, setSuggestion] = React.useState("");
  const [pending, setPending] = React.useState(false);

  const inputRef = React.useRef<HTMLInputElement>(null);
  React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);

  const abortRef = React.useRef<AbortController | null>(null);
  const timerRef = React.useRef<number | undefined>(undefined);

  const cancelPending = React.useCallback(() => {
    window.clearTimeout(timerRef.current);
    abortRef.current?.abort();
    setPending(false);
  }, []);

  React.useEffect(() => cancelPending, [cancelPending]);

  const requestSuggestion = React.useCallback(
    (nextValue: string) => {
      cancelPending();
      setSuggestion("");

      if (nextValue.trim().length < minChars) return;

      timerRef.current = window.setTimeout(() => {
        const controller = new AbortController();
        abortRef.current = controller;
        setPending(true);

        fetchSuggestion(nextValue, controller.signal)
          .then((result) => {
            if (controller.signal.aborted || !result) return;
            // A suggestion that doesn't continue the text the user is now looking at
            // is stale (the field may have changed while the request was in flight)
            // and would insert the wrong thing if accepted — discard it rather than
            // showing something misleading.
            if (result === nextValue || !result.startsWith(nextValue)) return;
            setSuggestion(result.slice(nextValue.length));
          })
          .catch((error: unknown) => {
            if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
          })
          .finally(() => {
            if (abortRef.current === controller) setPending(false);
          });
      }, debounceMs);
    },
    [cancelPending, debounceMs, fetchSuggestion, minChars]
  );

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const nextValue = event.target.value;
    onValueChange(nextValue);
    requestSuggestion(nextValue);
  };

  const acceptSuggestion = () => {
    if (!suggestion) return false;
    const accepted = value + suggestion;
    onValueChange(accepted);
    setSuggestion("");
    cancelPending();
    onAcceptSuggestion?.(accepted);
    return true;
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;

    const input = event.currentTarget;
    const caretAtEnd = input.selectionStart === value.length && input.selectionEnd === value.length;

    if (suggestion && event.key === "Tab" && !event.shiftKey) {
      event.preventDefault();
      acceptSuggestion();
      return;
    }

    if (suggestion && event.key === "ArrowRight" && caretAtEnd) {
      event.preventDefault();
      acceptSuggestion();
      return;
    }

    if (suggestion && event.key === "Escape") {
      event.preventDefault();
      setSuggestion("");
      cancelPending();
    }
  };

  const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
    onBlur?.(event);
    setSuggestion("");
    cancelPending();
  };

  return (
    <div className={cn("relative", wrapperClassName)}>
      {/* Mirrors the input's box exactly. The typed-text span is transparent so it
          takes up the right amount of space without being visible twice; only the
          suggestion tail — which sits past whatever the real input has painted — is
          actually seen. aria-hidden because the accessible description below carries
          the same information to assistive tech. */}
      <div
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-pre",
          "px-3 text-sm",
          className
        )}
      >
        <span className="text-transparent">{value}</span>
        <span className="text-muted-foreground/70 transition-opacity duration-[var(--pd-duration-fast)] ease-[var(--pd-ease-standard)]">
          {suggestion}
        </span>
      </div>

      <input
        ref={inputRef}
        type="text"
        value={value}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        onBlur={handleBlur}
        role="combobox"
        aria-expanded={Boolean(suggestion)}
        aria-autocomplete="inline"
        aria-describedby={suggestion ? `${props.id ?? "ghost-input"}-suggestion-hint` : props["aria-describedby"]}
        className={cn(
          "relative h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none",
          "placeholder:text-muted-foreground",
          "focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring",
          "disabled:pointer-events-none disabled:opacity-50",
          "aria-invalid:border-destructive aria-invalid:ring-destructive/20",
          className
        )}
        {...props}
      />

      {suggestion ? (
        <>
          <span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
            Tab
          </span>
          <span id={`${props.id ?? "ghost-input"}-suggestion-hint`} className="sr-only" aria-live="polite">
            Suggestion available: {suggestion}. Press Tab to accept, Escape to dismiss.
          </span>
        </>
      ) : null}

      {pending ? <span className="sr-only" aria-live="polite">Fetching suggestion</span> : null}
    </div>
  );
});

export { GhostInput };