Prompt Submit Lift
The composed prompt lifts out of the field and docks as a sent message while the thread makes room.
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 · Prompt Submit Lift
*
* Sending a prompt as a handoff rather than a cut: the composed text
* lifts out of the field, the thread above makes room, and the message
* docks at the bottom of the conversation.
*
* 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`, `prompt`, `history`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PromptMessage = {
role: "user" | "assistant";
text: string;
};
export type PromptSubmitLiftProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Text pre-composed in the field. */
prompt?: string;
/** Turns already in the thread. */
history?: PromptMessage[];
/** Shown once the field is empty. */
placeholder?: string;
/** Height of the visible thread, in px. */
threadHeight?: number;
/** Accent for the user bubble and the send control. */
color?: string;
};
type VariantConfig = {
/** px the composed text travels as it leaves the field. */
liftY: number;
/** ms between the lift starting and the bubble docking. */
handoffMs: number;
/** px the docked bubble rises from. */
enterY: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8. Every
// bubble in the thread moves on this spring, so a single overshoot is
// multiplied by the whole conversation.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.07 — no overshoot, short handoff. For high-frequency chat.
subtle: {
liftY: 14,
handoffMs: 90,
enterY: 8,
spring: { type: "spring", stiffness: 460, damping: 46 },
},
// ζ ≈ 0.93 — lands clean. The all-purpose setting.
default: {
liftY: 22,
handoffMs: 120,
enterY: 14,
spring: { type: "spring", stiffness: 420, damping: 38 },
},
// ζ ≈ 0.82 — one soft settle and a longer lift, for a hero composer.
playful: {
liftY: 30,
handoffMs: 150,
enterY: 20,
spring: { type: "spring", stiffness: 380, damping: 32 },
},
};
const SAMPLE_HISTORY: PromptMessage[] = [
{ role: "user", text: "Pull the Q3 revenue numbers." },
{ role: "assistant", text: "Q3 closed at $1.24M, up 6.2% on Q2." },
];
/** 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. The accent stays literal:
* it is a brand color, not a surface. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function PromptSubmitLift({
variant = "default",
prompt = "Break that down by region",
history = SAMPLE_HISTORY,
placeholder = "Ask a follow-up",
threadHeight = 150,
color = "#5B5BD6",
}: PromptSubmitLiftProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [messages, setMessages] = useState<PromptMessage[]>(history);
const [draft, setDraft] = useState(prompt);
const [lifting, setLifting] = useState<string | null>(null);
// The bubble docks a beat after the lift starts, so the two read as one
// continuous handoff rather than two separate animations.
useEffect(() => {
if (lifting === null) return;
const dock = setTimeout(() => {
setMessages((list) => [...list, { role: "user", text: lifting }]);
setLifting(null);
}, cfg.handoffMs);
return () => clearTimeout(dock);
}, [lifting, cfg.handoffMs]);
const send = () => {
const text = draft.trim();
if (!text || lifting !== null) return;
setDraft("");
// Reduced motion: no lift, the bubble simply arrives.
if (reduceMotion) {
setMessages((list) => [...list, { role: "user", text }]);
return;
}
setLifting(text);
};
return (
<div style={{ width: 320, display: "grid", gap: 10 }}>
<div
style={{
height: threadHeight,
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
gap: 7,
overflow: "hidden",
}}
>
{messages.map((message, index) => (
<motion.div
key={`${index}-${message.text}`}
// Position-only layout: bubbles slide up to make room without
// their boxes being interpolated, so no glyph is ever
// stretched by the reflow.
layout={reduceMotion ? false : "position"}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: index < history.length ? 0 : cfg.enterY }
}
animate={{ opacity: 1, y: 0 }}
transition={{
layout: cfg.spring,
y: cfg.spring,
opacity: { duration: 0.2, ease: "easeOut" },
}}
style={{
alignSelf: message.role === "user" ? "flex-end" : "flex-start",
maxWidth: 232,
padding: "8px 12px",
borderRadius:
message.role === "user"
? "15px 15px 4px 15px"
: "15px 15px 15px 4px",
background: message.role === "user" ? color : tone(8),
color: message.role === "user" ? "#fff" : "inherit",
fontSize: 13,
lineHeight: 1.45,
}}
>
{message.text}
</motion.div>
))}
</div>
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 9px 9px 14px",
borderRadius: 999,
background: tone(5),
border: `1px solid ${tone(13)}`,
}}
>
<span
style={{
flex: 1,
fontSize: 13,
lineHeight: 1.4,
opacity: draft ? 0.82 : 0.38,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{draft || placeholder}
</span>
{/* The copy that leaves. It starts exactly where the composed
text sat and rises toward the thread, clearing the field
before the bubble lands there. */}
<AnimatePresence>
{lifting !== null && (
<motion.span
key="lift"
aria-hidden
initial={{ opacity: 0.82, y: 0 }}
animate={{ opacity: 0, y: -cfg.liftY }}
exit={{ opacity: 0 }}
transition={{
y: { duration: 0.3, ease: [0.22, 1, 0.36, 1] },
opacity: { duration: 0.26, ease: "easeIn" },
}}
style={{
position: "absolute",
left: 14,
right: 48,
fontSize: 13,
lineHeight: 1.4,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
pointerEvents: "none",
}}
>
{lifting}
</motion.span>
)}
</AnimatePresence>
<motion.button
type="button"
onClick={send}
disabled={!draft.trim()}
aria-label="Send prompt"
whileTap={reduceMotion || !draft.trim() ? undefined : { scale: 0.9 }}
animate={{ opacity: draft.trim() ? 1 : 0.4 }}
transition={{
scale: { type: "spring", stiffness: 560, damping: 46 },
opacity: { duration: 0.18, ease: "easeOut" },
}}
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
flex: "0 0 auto",
borderRadius: "50%",
border: "none",
background: color,
color: "#fff",
cursor: draft.trim() ? "pointer" : "default",
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M7 11.6V2.6M3.2 6.2 7 2.4l3.8 3.8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.button>
</div>
</div>
);
}About this pattern
Submitting a prompt is the moment a draft stops being yours and becomes part of the record, and a field that simply blanks throws that away. Here the composed text rises out of the input and fades as it goes, the thread above shifts up on a position-only layout animation, and the message docks a beat later at the bottom of the conversation. Position-only is the important part: the bubbles move without their boxes being interpolated, so no glyph is stretched by the reflow.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
The composed prompt leaves the composer and settles into the thread as the newest turn.