All patterns

Prompt Submit Lift

The composed prompt lifts out of the field and docks as a sent message while the thread makes room.

aifriendlypremiuminteraction · finite · intermediate · ~0.6s
Interactive · click to play
Variant

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.

269 lines · react + motion only
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.

Send a chat promptComposer to thread handoffMessage submissionFollow-up question

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    The composed prompt leaves the composer and settles into the thread as the newest turn.

Related patterns