Skip to content
KONIGI Patterns

AI Assistants / Help within apps / Ghost text

‹ 3 of 5 ›

Ghost text

Writers need to distinguish suggested words from text they have already accepted.

Updated September 12, 2026

Problem

People need to distinguish suggested text from their own writing while deciding whether to accept it.

Solution

Show a suggestion after the cursor, with styling that clearly marks it as unaccepted text. Keep it readable and use more than faint color alone to distinguish it.

Offer suggestions after a pause in typing. Keep them stable long enough to read, and make dismissal easy. Show the acceptance shortcut without interfering with normal keyboard navigation.

Let people accept part of a longer suggestion when that is useful. Provide a way to turn suggestions off. Evaluate whether accepted text stays in the document, rather than treating every acceptance as a successful suggestion.

Use when

The surrounding text gives enough context for a short completion, and unwanted suggestions are easy 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

Suggestions can interrupt writing or be accepted by mistake. They can also steer a writer toward the assistant’s wording. Generating suggestions people ignore still adds cost, so frequency matters.

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 runs when people request help with selected text. Suggestion diff handles larger proposed changes. AI entry point helps people discover the feature.

Streaming response shows an answer as it arrives. Response actions provide controls for completed chat answers.

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 screenshots yet for this pattern. Browse Products to see other examples.