Inline Completion Ghost
A faint prediction appears ahead of the caret and firms up to real content when it is accepted.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Inline Completion Ghost
*
* The prediction an editor offers ahead of the caret: it fades in faint
* and grey, and on accept it firms up to real text while the caret moves
* past it to the new end of the line.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `typed`, `completion`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type InlineCompletionGhostProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** What the person has already entered. */
typed?: string;
/** The continuation offered ahead of the caret. */
completion?: string;
/** ms before the prediction appears. */
offerDelayMs?: number;
/** ms the prediction is held before it is taken. */
acceptDelayMs?: number;
/** Key shown in the accept hint. */
acceptKey?: string;
/** Accent for the hint chip. */
color?: string;
};
type VariantConfig = {
/** Opacity the prediction rests at before it is accepted. */
ghostOpacity: number;
/** Seconds for the prediction to appear. */
offerSeconds: number;
/** Seconds for it to firm up once accepted. */
firmSeconds: number;
/** px the prediction slides in from. */
driftX: number;
/** Caret travel to the new end of the line. */
spring: { type: "spring"; stiffness: number; damping: number };
};
// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8, and
// the caret is the only thing that ever moves horizontally. A caret that
// overshoots its own line end looks like a bug, not a flourish.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost no drift; the prediction simply resolves. For editors where
// suggestions appear on nearly every keystroke.
subtle: {
ghostOpacity: 0.33,
offerSeconds: 0.14,
firmSeconds: 0.15,
driftX: 0,
spring: { type: "spring", stiffness: 580, damping: 47 },
},
// ζ ≈ 0.89 — the all-purpose setting.
default: {
ghostOpacity: 0.4,
offerSeconds: 0.22,
firmSeconds: 0.2,
driftX: 3,
spring: { type: "spring", stiffness: 500, damping: 40 },
},
// A touch more drift and a slower resolve, for a single prominent
// composer rather than a code surface.
playful: {
ghostOpacity: 0.45,
offerSeconds: 0.3,
firmSeconds: 0.28,
driftX: 7,
spring: { type: "spring", stiffness: 430, damping: 35 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function InlineCompletionGhost({
variant = "default",
typed = "Summarize the vendor invoices from ",
completion = "the March billing cycle",
offerDelayMs = 700,
acceptDelayMs = 1900,
acceptKey = "Tab",
color = "#7C7CF0",
}: InlineCompletionGhostProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [phase, setPhase] = useState<"idle" | "offered" | "accepted">("idle");
// One timer per phase, scheduled from the current phase: the sequence
// advances by rescheduling itself and cleans itself up on unmount.
useEffect(() => {
if (phase === "accepted") return;
const offered = phase === "offered";
const wait = offered
? Math.max(0, acceptDelayMs - offerDelayMs)
: offerDelayMs;
const timer = setTimeout(
() => setPhase(offered ? "accepted" : "offered"),
wait
);
return () => clearTimeout(timer);
}, [phase, offerDelayMs, acceptDelayMs]);
const offered = phase !== "idle";
const accepted = phase === "accepted";
// The caret and the prediction are siblings that swap order on accept.
// Keyed, so React moves the node rather than replacing it, and
// `layout="position"` turns that move into a slide to the new line end.
const caret = (
<motion.span
key="caret"
aria-hidden
layout={reduceMotion ? false : "position"}
animate={reduceMotion ? { opacity: 1 } : { opacity: [1, 1, 0, 0, 1] }}
transition={{
layout: cfg.spring,
// A caret blinks; that is what a caret is. Under reduced motion
// it holds solid instead, which still marks the insertion point.
opacity: reduceMotion
? { duration: 0 }
: {
duration: 1.1,
repeat: Infinity,
ease: "linear",
times: [0, 0.45, 0.5, 0.95, 1],
},
}}
style={{
display: "inline-block",
width: 1.5,
height: "1.05em",
marginLeft: 1,
marginRight: 1,
verticalAlign: "text-bottom",
background: "currentColor",
borderRadius: 1,
}}
/>
);
// The prediction is split into per-word boxes purely so a long
// completion wraps with the line instead of being bumped to a row of
// its own. They share one animation with no stagger, so it still
// resolves as a single block rather than arriving word by word.
const ghost = completion.split(" ").map((word, index, all) => (
<motion.span
key={`ghost-${index}`}
initial={false}
// The prediction never moves vertically and never changes size —
// it only firms up, from a faint draft to real content.
animate={{
opacity: offered ? (accepted ? 1 : cfg.ghostOpacity) : 0,
x: offered || reduceMotion ? 0 : cfg.driftX,
}}
transition={{
opacity: {
duration: accepted ? cfg.firmSeconds : cfg.offerSeconds,
ease: "easeOut",
},
x: { duration: cfg.offerSeconds, ease: "easeOut" },
}}
style={{ display: "inline-block", whiteSpace: "pre" }}
>
{index === all.length - 1 ? word : `${word} `}
</motion.span>
));
return (
<div
style={{
width: 320,
padding: "12px 13px 11px",
borderRadius: 12,
background: tone(5),
border: `1px solid ${tone(13)}`,
}}
>
<div
style={{
fontSize: 13,
lineHeight: 1.65,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
minHeight: 43,
}}
>
<span style={{ whiteSpace: "pre-wrap" }}>{typed}</span>
{accepted ? [...ghost, caret] : [caret, ...ghost]}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
marginTop: 10,
paddingTop: 9,
borderTop: `1px solid ${tone(10)}`,
minHeight: 22,
}}
>
<span style={{ fontSize: 11, opacity: 0.4 }}>
{accepted ? "Completion accepted" : "Inline completion"}
</span>
<AnimatePresence initial={false}>
{offered && !accepted && (
<motion.span
key="hint"
// Reduced motion: the hint appears and leaves on opacity
// alone. It is a keyboard affordance, so it must be
// visible either way.
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: 11,
opacity: 0.62,
}}
>
<span
style={{
padding: "1.5px 6px",
borderRadius: 5,
background: tone(9),
border: `1px solid ${tone(14)}`,
color,
fontWeight: 650,
fontSize: 10.5,
}}
>
{acceptKey}
</span>
to accept
</motion.span>
)}
</AnimatePresence>
</div>
</div>
);
}About this pattern
A prediction offered inside the field has to be obviously provisional and obviously free to ignore. Opacity does that work: the continuation resolves in at around 40% and simply stays there, contributing nothing but a suggestion, until it is taken and firms up to full strength. The caret and the prediction are keyed siblings that swap order on accept, so a position-only layout animation slides the caret to the new end of the line — the one movement in the pattern, and the one that says the offer became real.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A dimmed continuation rendered ahead of the caret and taken with a single key.
Related patterns
- Autocomplete RiseA completion panel lifts into place under the field, its rows landing a beat apart.
- Token Budget MeterThe fill climbs to the conversation's usage while its colour walks to amber, and the trim notice fades in at the threshold.
- Agent Step TimelineEach finished stage of an agent run ticks over and grows its connector toward the one after it.