Skip to content
KONIGI

AI Assistants / In-product assistance / Ghost text

3 of 5

Ghost text

A suggestion has to be readable in place and obviously not yet real.

Updated September 12, 2026

Problem

A completion appears after the cursor, in the same line, in the same font, in the place where the viewer’s own next words would go. It has to be legible enough to judge and unmistakable enough that nobody ever believes they wrote it.

Solution

Render the suggestion inline in a lighter weight of the same typeface, after the insertion point, and accept it with Tab. Those three choices are close to universal because each one is doing work: inline because the value is seeing it in its actual position, lighter because the state has to be visible without reading, and Tab because it’s adjacent to typing and unclaimed in most text contexts.

Contrast is the hard constraint and the one most implementations get wrong. Grey text has to be light enough to read as provisional and dark enough to be read at all, and the window between those is narrow. Pushed too light it fails accessibility guidance outright and becomes unusable for anyone with low vision, in bright light, or on a poor display. Pushed too dark it stops reading as a suggestion, and someone eventually ships a document with a completion they never accepted. The honest resolution is to carry the state in something other than contrast alone, whether that’s a subtle background, an italic, or a marker in the gutter.

Timing is the second constraint, and HAX guideline 3 names it directly: time services based on context. A completion offered on every keystroke is chaos, because it changes while the viewer is still forming the sentence it’s trying to finish. Debouncing to a pause in typing produces far fewer and far better suggestions. The related failure is the suggestion that vanishes the instant the viewer looks at it, dismissed by a keystroke before it could be evaluated.

Accept needs gradations. All-or-nothing is a poor fit for a three-line suggestion whose first clause is right. Word-by-word acceptance, usually on a modifier plus the right arrow, turns a rejected suggestion into a partially useful one and is the difference between a tool that helps experienced writers and one they turn off.

Prose and code behave differently enough to be worth separating. Code has strong structural cues, an established completion tradition, and readers who already scan for shape. Prose completion sits in the same faculty the writer is using to compose, so it interrupts more and has to be quieter and rarer to be tolerable.

Acceptance rate is the obvious metric and misleading on its own, since a suggestion accepted and then rewritten cost more than none at all. Retention of accepted text is the number that means something.

Use when

The next few words are predictable from strong context, and the viewer is producing text in a place where a wrong guess is cheap to dismiss.

Don’t use when

The writing is short, deliberate, or high-stakes. A subject line, a legal clause, or a commit message gets no benefit and carries the full risk of an unnoticed accepted completion.

Trade-offs

Ghost text is the least interruptive way to offer help and the easiest to accept by accident, and those are the same property. Making it more visible makes it more intrusive to people who didn’t want it. It also shapes what gets written: a writer offered a plausible continuation tends to take it, so the tool’s voice leaks into the work in a way nobody consented to sentence by sentence. Every suggestion costs an inference whether or not anyone reads it. Per unit of value, nothing else here is as expensive.

Checklist

  • Does the suggestion meet contrast requirements while still reading as provisional?
  • Is the provisional state carried by anything other than colour?
  • What triggers a suggestion, and how long is the pause before it appears?
  • Can part of a suggestion be accepted?
  • What dismisses it, and can it be recovered after an accidental dismissal?
  • Does a multi-line suggestion shift the content below the cursor?
  • What does a screen reader announce, and when?
  • Is it disabled in fields where an accidental accept would be expensive?
  • Is retention of accepted text measured, or only acceptance?
  • Can the viewer turn it off in one place they can find?

Compare

GitHub Copilot presents a completion whole rather than streaming it, accepts on Tab and supports partial acceptance by word. The suggestion is an object to evaluate rather than a process to watch. Google Docs applies the same mechanic to prose with far shorter and rarer suggestions, conceding that prose tolerates less interruption than code. Notion mostly avoids the pattern in favour of explicit invocation at the cursor. Ambient help goes, and nothing ever appears unasked. Superhuman scopes it tightly to email, where the context is strong, the phrasing is formulaic, and a wrong guess costs one keystroke to clear.

Inline assist is the explicit version, invoked on a selection rather than offered unprompted. Suggestion diff is how larger changes get proposed when ghost text is too small a container. AI entry point covers how an ambient feature announces itself. Streaming response is the deliberate opposite choice about whether to show output as it arrives. Response actions is the equivalent control row where output is a chat turn.

Ghost text anatomy A completion offered inline after the caret in a lighter weight, with word-by-word acceptance marked, a contrast scale showing the narrow window between unreadable and indistinguishable, and a debounce interval before the suggestion appears. Readable enough to judge, distinct enough never to be mistaken tab takes one word committed offered too light to read, too dark to tell apart typing pause, then suggest every suggestion costs an inference, read or not 1 2 3 4 1 INLINE, AFTER THE CARET The value is seeing it in the position it would actually occupy. 2 PARTIAL ACCEPTANCE All or nothing is a poor fit when the first clause is right and the rest is not. 3 THE CONTRAST WINDOW Carry the state in something besides colour, because the window is narrow. 4 WAIT FOR A PAUSE A completion on every keystroke changes while the sentence is still forming. Acceptance rate flatters. Retention of accepted text is the number that means something.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A completion offered after the caret in a lighter weight, accepted a word at a time. The usable contrast window is narrow: too light to read on one side, indistinguishable from committed text on the other.

Tokens
--card--card-foreground--foreground--muted-foreground--border--input--ring
Suggestion: for the workspace, not for the people in it.

tab takes one word · ⌘→ takes all of it · esc dismisses

GhostText.tsxA textarea over a mirror, so the suggestion starts at the caret. Asks for a suggestion on a pause, Tab takes a word, Escape dismisses and stays dismissed, a live region announces it once.

import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

/**
 * A completion after the caret, in the place the viewer's next words would go.
 *
 * The textarea is real and sits on top; underneath it a mirror repeats the
 * committed text invisibly so the suggestion starts exactly where the caret
 * is. The suggestion is muted and italic, because the muted grey alone has
 * a narrow window between unreadable and indistinguishable, and the slant
 * carries the provisional state when the grey is at either edge of it.
 */
type Props = {
  value: string;
  onChange: (value: string) => void;
  /** Asked once the typing has paused, never per keystroke. Return null for
   *  no suggestion. */
  suggest: (value: string) => string | null | Promise<string | null>;
  /** How long a pause is. Shorter gets more suggestions and worse ones. */
  pauseMs?: number;
  /** A suggestion to show before the first pause. Mostly for a server render. */
  initialSuggestion?: string | null;
  placeholder?: string;
  className?: string;
};

/** The first word of the suggestion and the space after it. */
const firstWord = (s: string) => s.match(/^\s*\S+\s?/)?.[0] ?? s;

export function GhostText({ value, onChange, suggest, pauseMs = 400, initialSuggestion = null, placeholder, className }: Props) {
  const [suggestion, setSuggestion] = useState<string | null>(initialSuggestion);
  // The value a dismissed suggestion belonged to. The next pause on the same
  // text stays quiet rather than offering back the thing that was just refused.
  const dismissedFor = useRef<string | null>(null);

  useEffect(() => {
    if (dismissedFor.current === value) return;
    const t = setTimeout(async () => setSuggestion(await suggest(value)), pauseMs);
    return () => clearTimeout(t);
  }, [value, pauseMs, suggest]);

  const accept = (text: string) => {
    onChange(value + text);
    const rest = suggestion!.slice(text.length);
    setSuggestion(rest.length ? rest : null);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (!suggestion) return;
    if (e.key === "Tab") { e.preventDefault(); accept(firstWord(suggestion)); }
    else if (e.key === "ArrowRight" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); accept(suggestion); }
    else if (e.key === "Escape") { e.preventDefault(); dismissedFor.current = value; setSuggestion(null); }
    // Any other key: the suggestion stays until the pause timer replaces it,
    // so it is not dismissed by the keystroke that was already in flight.
  };

  const editor = "whitespace-pre-wrap break-words px-3 py-2 text-sm leading-relaxed";

  return (
    <div className={cn("relative rounded-md border border-input bg-card", className)}>
      <div aria-hidden="true" className={cn(editor, "pointer-events-none min-h-[60px]")}>
        <span className="invisible">{value}</span>
        {suggestion && <span className="italic text-muted-foreground">{suggestion}</span>}
      </div>
      <textarea
        value={value}
        onChange={(e) => { dismissedFor.current = null; onChange(e.target.value); }}
        onKeyDown={onKeyDown}
        placeholder={placeholder}
        spellCheck={false}
        className={cn(editor, "absolute inset-0 h-full w-full resize-none overflow-hidden bg-transparent text-card-foreground caret-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring")}
      />
      {/* Sighted readers see the grey; a screen reader hears it once, on arrival. */}
      <div className="sr-only" aria-live="polite">{suggestion ? `Suggestion: ${suggestion}` : ""}</div>
      {suggestion && (
        <p className="border-t px-3 py-1.5 text-[11px] text-muted-foreground">
          tab takes one word <span className="mx-1.5">·</span> ⌘→ takes all of it <span className="mx-1.5">·</span> esc dismisses
        </p>
      )}
    </div>
  );
}

demo.tsxHow it is called: the pricing page draft, with completions looked up from how the sentence ends.

import { useState } from "react";
import { GhostText } from "./GhostText";

/**
 * The pricing page draft. The completions are a lookup on how the sentence
 * ends, which is enough to show the mechanics: a pause brings one, Tab takes
 * a word of it, and typing something else lets the next pause replace it.
 */
const COMPLETIONS: [ending: RegExp, text: string][] = [
  [/the price is for the workspace, $/, "not for the people in it."],
  [/the price is for the $/, "workspace, not for the people in it."],
  [/the price is $/, "for the workspace, not for the people in it."],
  [/unlimited seats\.? ?$/, " The price is for the workspace, not for the people in it."],
  [/\bper (seat|person)\b[^.]*$/, ", which is the case with every plan we sell."],
];

const suggest = (value: string) =>
  COMPLETIONS.find(([ending]) => ending.test(value))?.[1] ?? null;

export default function Demo() {
  const [draft, setDraft] = useState("Every plan includes unlimited seats, so the price is ");

  return (
    <GhostText
      value={draft}
      onChange={setDraft}
      suggest={suggest}
      initialSuggestion={suggest(draft)}
      placeholder="Pricing page"
    />
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.