AI & Chat

Inline Ghost Completion

An input that offers a ghost completion in muted text; Tab solidifies it into real text with a crossfade and Esc dismisses it.

Install

npx shadcn@latest add @paragon/inline-ghost-completion

inline-ghost-completion.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface InlineGhostCompletionProps
  extends Omit<
    React.ComponentProps<"div">,
    "onChange" | "children" | "onInput"
  > {
  /**
   * Suggests a completion for the current input value. Return the full string
   * (including the typed prefix) or null for no suggestion. Called on change.
   */
  suggest: (value: string) => string | null;
  /** Controlled value. */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Fires when a ghost completion is accepted. */
  onAccept?: (value: string) => void;
  placeholder?: string;
  static?: boolean;
}

/**
 * A single-line input that offers a ghost completion in muted text as you
 * type. Tab (or → at the end) solidifies the ghost into real text with a brief
 * crossfade — the accepted tail fades from muted to foreground; Esc dismisses
 * it. The ghost is a non-interactive overlay perfectly aligned under the input
 * text. Fully keyboard-driven; reduced motion drops the crossfade.
 */
export function InlineGhostCompletion({
  suggest,
  value: controlled,
  defaultValue = "",
  onValueChange,
  onAccept,
  placeholder = "Ask anything…",
  static: isStatic = false,
  className,
  ...props
}: InlineGhostCompletionProps) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const isControlled = controlled !== undefined;
  const value = isControlled ? controlled : uncontrolled;

  const [ghost, setGhost] = React.useState<string | null>(null);
  const [flash, setFlash] = React.useState<string | null>(null);
  const flashTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(
    () => () => {
      if (flashTimeout.current) clearTimeout(flashTimeout.current);
    },
    [],
  );

  const setValue = (next: string) => {
    if (!isControlled) setUncontrolled(next);
    onValueChange?.(next);
  };

  const recompute = (next: string) => {
    if (!next) {
      setGhost(null);
      return;
    }
    const s = suggest(next);
    setGhost(s && s.length > next.length && s.startsWith(next) ? s : null);
  };

  const accept = () => {
    if (!ghost) return;
    const tail = ghost.slice(value.length);
    setValue(ghost);
    setGhost(null);
    onAccept?.(ghost);
    if (!isStatic) {
      setFlash(tail);
      if (flashTimeout.current) clearTimeout(flashTimeout.current);
      flashTimeout.current = setTimeout(() => setFlash(null), 320);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (!ghost) return;
    const el = e.currentTarget;
    const atEnd = el.selectionStart === value.length && el.selectionEnd === value.length;
    if (e.key === "Tab" || (e.key === "ArrowRight" && atEnd)) {
      e.preventDefault();
      accept();
    } else if (e.key === "Escape") {
      e.preventDefault();
      setGhost(null);
    }
  };

  const suffix = ghost ? ghost.slice(value.length) : "";

  return (
    <div
      data-slot="inline-ghost-completion"
      className={cn("relative w-full max-w-md", className)}
      {...props}
    >
      {/* Overlay mirrors the input's text box exactly; ghost tail is muted. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm whitespace-pre"
      >
        {/* Prefix sizer keeps the ghost/flash aligned with the input text. */}
        <span className="invisible">{flash ? value.slice(0, value.length - flash.length) : value}</span>
        {suffix && <span className="text-muted-foreground/60">{suffix}</span>}
        {flash && (
          <span
            key={flash}
            style={{
              animation: isStatic
                ? undefined
                : "paragon-ghost-solidify 300ms var(--ease-out) forwards",
            }}
          >
            {flash}
          </span>
        )}
      </div>

      <style href="paragon-inline-ghost-completion" precedence="paragon">{`
        @keyframes paragon-ghost-solidify {
          from { color: var(--color-muted-foreground); filter: blur(1px); }
          to { color: var(--color-foreground); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-slot="inline-ghost-completion"] * { animation: none !important; }
        }
      `}</style>

      <input
        type="text"
        value={value}
        placeholder={placeholder}
        aria-label={placeholder}
        aria-autocomplete="inline"
        autoComplete="off"
        spellCheck={false}
        onChange={(e) => {
          setValue(e.target.value);
          recompute(e.target.value);
          setFlash(null);
        }}
        onKeyDown={handleKeyDown}
        className="relative h-10 w-full rounded-lg border border-input bg-card px-3 text-sm text-foreground shadow-border transition-[box-shadow] duration-(--duration-fast) outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
      />

      {ghost && (
        <span className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded border border-border bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
          Tab
        </span>
      )}
    </div>
  );
}