An input that offers a ghost completion in muted text; Tab solidifies it into real text with a crossfade and Esc dismisses it.
npx shadcn@latest add @paragon/inline-ghost-completion"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>
);
}