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.
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.
"use client"
import * as React from "react"
import { GhostText } from "mischief-ui/ghost-text"
const subjects = [
"Refund for the duplicate charge on your March invoice",
"Your export is ready to download",
"Resetting two-factor authentication on your account",
]
const sentences = [
"Thanks for writing in about the duplicate charge.",
"I have refunded the second payment to the card you used.",
"It can take five to ten business days to appear on your statement.",
"Your plan and your saved projects are not affected.",
"Reply here if anything still looks wrong and I will take another look.",
]
function wait(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms)
signal.addEventListener("abort", () => {
clearTimeout(timer)
reject(signal.reason)
})
})
}
function completeFrom(options: string[], fragment: string) {
if (fragment.trim().length < 2) return ""
const lower = fragment.toLowerCase()
const match = options.find(
(option) =>
option.toLowerCase().startsWith(lower) && option.length > fragment.length
)
return match ? match.slice(fragment.length) : ""
}
async function suggestSubject(text: string, signal: AbortSignal) {
await wait(180, signal)
return completeFrom(subjects, text)
}
async function suggestReply(text: string, signal: AbortSignal) {
await wait(240, signal)
const fragment = text.slice(
Math.max(text.lastIndexOf(". "), text.lastIndexOf("\n")) + 1
)
const trimmed = fragment.trimStart()
// After a finished sentence, offer the next one nobody has written yet.
if (trimmed === "" && /[.!?]\s*$/.test(text)) {
const next = sentences.find((sentence) => !text.includes(sentence))
if (!next) return ""
return text.endsWith(" ") || text.endsWith("\n") ? next : ` ${next}`
}
return completeFrom(sentences, trimmed)
}
export function GhostTextDemo() {
const [subject, setSubject] = React.useState("")
const [reply, setReply] = React.useState("Hi Priya,\n\n")
return (
<div className="bg-background border-border grid w-full max-w-[34rem] gap-4 rounded-[1.25rem] border p-5 shadow-sm">
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="ghost-text-subject">
Subject
</label>
<GhostText
id="ghost-text-subject"
getSuggestion={suggestSubject}
onValueChange={setSubject}
placeholder="Try typing Refund"
value={subject}
/>
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="ghost-text-reply">
Reply
</label>
<GhostText
id="ghost-text-reply"
getSuggestion={suggestReply}
multiline
onValueChange={setReply}
rows={6}
value={reply}
/>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
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.
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/ghost-textimport { 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.
"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?: stringUsage
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)}
/>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.