Mischief

121 / Agent UI

Ghost Text

An input or textarea that shows a suggested completion right after the caret. Tab takes it, Escape puts it away, and typing the suggested letters keeps it in place.

When a suggestion appears, press Tab to accept it or Escape to dismiss it.
When a suggestion appears, press Tab to accept it or Escape to dismiss it.

Start with Thanks. Tab takes the whole suggestion, Ctrl or Cmd with the right arrow takes one word, and Escape puts it away. Typing the suggested letters keeps it in place.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/ghost-text
import { GhostText } from "mischief-ui/ghost-text"

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/ghost-text/ghost-text.tsx
"use client" import * as React from "react" import { cn } from "@/lib/utils" type FieldElement = HTMLInputElement | HTMLTextAreaElement export type GhostTextProps = Omit<  React.HTMLAttributes<FieldElement>,  "children" | "defaultValue" | "onChange"> & {  value?: string  defaultValue?: string

Usage

export function Subject() {
  const [subject, setSubject] = useState("")

  return (
    <GhostText
      aria-label="Subject"
      value={subject}
      onValueChange={setSubject}
      getSuggestion={(text, signal) => completeSubject(text, { signal })}
    />
  )
}

Where suggestions come from

getSuggestion receives the text so far and returns what should come after it, as a string or a promise of one. An empty string means there is nothing to suggest. It is called after debounce milliseconds of quiet typing, only while the field has focus and is not empty.

<GhostText
  debounce={250}
  getSuggestion={async (text, signal) => {
    const response = await fetch("/api/complete", {
      method: "POST",
      body: JSON.stringify({ text }),
      signal,
    })
    return (await response.json()).completion
  }}
  onSuggestionError={(error) => report(error)}
/>
Pass the signal to fetch so a request for text that has moved on is cancelled.

A suggestion that arrives for text that has since changed is dropped, whether or not the request honoured the signal. A rejection leaves the field without a suggestion and goes to onSuggestionError.

Typing through a suggestion

Typing a character the suggestion predicted uses it up instead of asking again, so the rest of the suggestion stays exactly where it was. Typing anything else drops it, and a new one is asked for once typing pauses. Escape dismisses it, and nothing is asked for again until the text changes.

Suggestions only show while the caret is at the end of the text. A completion belongs after what has been written, not in the middle of it.

Keys

Tab takes the whole suggestion. Ctrl or Cmd with the right arrow takes the next word. Escape dismisses. With no suggestion showing, Tab moves focus as it always does, so the field never traps a keyboard user. On a touch keyboard, which has no Tab key, the small Tab mark after the suggestion is a button that takes it.

API

valuestringThe text, when controlled.
defaultValuestringThe starting text, when uncontrolled.
onValueChange(value: string) => voidCalled on every change, including an accepted suggestion.
getSuggestion(text: string, signal: AbortSignal) => string | Promise<string>Returns the completion to show after the text. An empty string means none.
debouncenumberMilliseconds of quiet before asking. Defaults to 300.
multilinebooleanRenders a textarea instead of an input.
rowsnumberTextarea rows when multiline. Defaults to 3.
onAccept(accepted: string) => voidReceives the part that was taken, whole or one word.
onSuggestionError(error: unknown) => voidReceives a rejected getSuggestion.
hintstringThe field's screen reader description of the keys.
disabled, readOnlybooleanEither one stops suggestions.
classNamestringClasses for the field itself.
...fieldPropsHTMLAttributesNative attributes for the input or textarea, such as id, name, and placeholder.

Accessibility

The field is a native input or textarea marked aria-autocomplete inline, and its description says that Tab accepts and Escape dismisses. A new suggestion is announced politely, read from the start of the word it completes, and is not announced again while it is being typed through. The muted text itself is hidden from assistive technology. Tab only accepts while a suggestion is visible, so focus moves normally otherwise. Escape stops at the suggestion, leaving the next press for a surrounding dialog. Reduced motion removes the fade the suggestion arrives with.