All patterns

Prompt Template Fill

Picking a starter writes its outline into the composer with the blanks lighting up.

aifriendlyenergeticinteraction · finite · starter · ~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.

295 lines · react + motion only
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Prompt Template Fill
 *
 * Choosing a starter writes its outline into the composer piece by
 * piece, with the blanks that still need an answer lighting up as they
 * land — so the reader sees both what was inserted and what is left to
 * do.
 *
 * Self-contained: depends only on `react` and `motion`. 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`, `templates`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TemplatePart = {
  /** The words themselves. */
  text: string;
  /** True when this piece is a blank the reader still has to fill. */
  slot?: boolean;
};

export type PromptTemplate = {
  /** Stable key. */
  id: string;
  /** Chip label. */
  label: string;
  /** The outline, split into literal pieces and blanks. */
  parts: TemplatePart[];
};

export type PromptTemplateFillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Starters offered above the composer. */
  templates?: PromptTemplate[];
  /** Shown in the composer before a starter is chosen. */
  placeholder?: string;
  /** Accent for the chosen chip and the blanks. */
  accent?: string;
  /** Fires with the id of the chosen starter. */
  onTemplateChange?: (id: string) => void;
};

type VariantConfig = {
  /** ms between one piece landing and the next. */
  cadenceMs: number;
  /** px a piece rises through. */
  riseY: number;
  /** Seconds a piece takes to resolve. */
  pieceSeconds: number;
  /** Seconds the blank's tint takes to come up behind it. */
  slotSeconds: number;
};

// Quality rule: the composed text is text, so pieces fade and rise a
// couple of pixels and never change size — a template that types itself
// by scaling each word is unreadable while it writes. Nothing springs;
// the blanks are marked by a tint coming up behind them, not by a jolt.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly instant. For a composer people fill from a starter every day.
  subtle: { cadenceMs: 26, riseY: 0, pieceSeconds: 0.1, slotSeconds: 0.18 },
  // A visible write-in with the blanks lighting up behind it. The
  // all-purpose setting.
  default: { cadenceMs: 55, riseY: 3, pieceSeconds: 0.16, slotSeconds: 0.26 },
  // Slower and more deliberate, for an onboarding moment where the
  // template is being taught.
  playful: { cadenceMs: 90, riseY: 6, pieceSeconds: 0.24, slotSeconds: 0.36 },
};

/** 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
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_TEMPLATES: PromptTemplate[] = [
  {
    id: "summarize",
    label: "Summarize",
    parts: [
      { text: "Summarize " },
      { text: "the support thread", slot: true },
      { text: " for " },
      { text: "an operations lead", slot: true },
      { text: ", keeping every commitment we made to the customer." },
    ],
  },
  {
    id: "reply",
    label: "Draft a reply",
    parts: [
      { text: "Write a reply to " },
      { text: "the last message", slot: true },
      { text: " in a " },
      { text: "warm, direct", slot: true },
      { text: " tone. Offer a next step and a date." },
    ],
  },
  {
    id: "compare",
    label: "Compare",
    parts: [
      { text: "Compare " },
      { text: "this quarter", slot: true },
      { text: " against " },
      { text: "the forecast", slot: true },
      { text: " and call out the three largest gaps." },
    ],
  },
];

export default function PromptTemplateFill({
  variant = "default",
  templates = DEFAULT_TEMPLATES,
  placeholder = "Pick a starter, or write your own",
  accent = "#5B5BD6",
  onTemplateChange,
}: PromptTemplateFillProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [chosenId, setChosenId] = useState<string | null>(null);
  const [written, setWritten] = useState(0);
  const [ring, setRing] = useState<string | null>(null);

  const chosen = templates.find((template) => template.id === chosenId) ?? null;
  const total = chosen?.parts.length ?? 0;
  const shown = chosen && reduceMotion ? total : written;

  // One timer per piece, scheduled from the current count: the outline
  // writes itself in and unmounting mid-write cleans up after itself.
  useEffect(() => {
    if (!chosen || reduceMotion || written >= total) return;
    const timer = setTimeout(
      () => setWritten((count) => count + 1),
      cfg.cadenceMs
    );
    return () => clearTimeout(timer);
  }, [chosen, written, total, reduceMotion, cfg.cadenceMs]);

  const choose = (template: PromptTemplate) => {
    setChosenId(template.id);
    setWritten(0);
    onTemplateChange?.(template.id);
  };

  const slotCount = chosen?.parts.filter((part) => part.slot).length ?? 0;

  return (
    <div style={{ width: 320, color: "inherit" }}>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 10 }}>
        {templates.map((template) => {
          const active = template.id === chosenId;
          return (
            <button
              key={template.id}
              type="button"
              onClick={() => choose(template)}
              aria-pressed={active}
              onFocus={(event) =>
                setRing(
                  event.currentTarget.matches(":focus-visible") ? template.id : null
                )
              }
              onBlur={() => setRing(null)}
              style={{
                display: "inline-flex",
                alignItems: "center",
                gap: 6,
                padding: "5px 11px",
                fontFamily: "inherit",
                fontSize: 11.5,
                fontWeight: 620,
                color: active ? "#fff" : "inherit",
                opacity: active ? 1 : 0.72,
                background: active ? accent : tone(7),
                border: `1px solid ${active ? accent : tone(13)}`,
                borderRadius: 999,
                cursor: "pointer",
                boxShadow: ring === template.id ? `0 0 0 3px ${tone(20)}` : "none",
                outline: "none",
                transition:
                  "background 160ms ease-out, color 160ms ease-out, opacity 160ms ease-out",
              }}
            >
              <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
                <path
                  d="M6 1.8v8.4M1.8 6h8.4"
                  stroke="currentColor"
                  strokeWidth="1.5"
                  strokeLinecap="round"
                  opacity={active ? 0.9 : 0.5}
                />
              </svg>
              {template.label}
            </button>
          );
        })}
      </div>

      <div
        style={{
          minHeight: 92,
          padding: "12px 13px",
          borderRadius: 12,
          border: `1px solid ${chosen ? tone(16) : tone(12)}`,
          background: tone(5),
          fontSize: 13,
          lineHeight: 1.65,
        }}
      >
        {!chosen && <span style={{ opacity: 0.4 }}>{placeholder}</span>}

        {chosen &&
          chosen.parts.slice(0, shown).map((part, index) => (
            <motion.span
              // Keyed by template as well as index so switching starters
              // re-writes rather than morphing one outline into another.
              key={`${chosen.id}-${index}`}
              initial={reduceMotion ? false : { opacity: 0, y: cfg.riseY }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: cfg.pieceSeconds, ease: "easeOut" }}
              style={{
                position: "relative",
                display: "inline",
                whiteSpace: "pre-wrap",
                fontWeight: part.slot ? 620 : 400,
                color: part.slot ? accent : "inherit",
                padding: part.slot ? "1px 4px" : undefined,
                borderRadius: part.slot ? 5 : undefined,
                // The blank's tint is a static mix that comes up behind
                // the words rather than an animated colour — color-mix()
                // results cannot be interpolated.
                background: part.slot
                  ? `color-mix(in srgb, ${accent} 13%, transparent)`
                  : undefined,
                boxShadow: part.slot
                  ? `inset 0 0 0 1px color-mix(in srgb, ${accent} 26%, transparent)`
                  : undefined,
              }}
            >
              {part.text}
            </motion.span>
          ))}

        {/* The caret sits at the write head while the outline is going
            in, and stops when there is nothing left to add. */}
        {chosen && shown < total && !reduceMotion && (
          <motion.span
            aria-hidden
            animate={{ opacity: [1, 1, 0, 0, 1] }}
            transition={{
              duration: 1,
              repeat: Infinity,
              ease: "linear",
              times: [0, 0.45, 0.5, 0.95, 1],
            }}
            style={{
              display: "inline-block",
              width: 2,
              height: "1em",
              marginLeft: 1,
              verticalAlign: "text-bottom",
              borderRadius: 1,
              background: accent,
            }}
          />
        )}
      </div>

      <div
        role="status"
        style={{
          display: "flex",
          alignItems: "center",
          gap: 6,
          marginTop: 8,
          fontSize: 11,
          opacity: 0.5,
        }}
      >
        {chosen
          ? shown < total
            ? "Writing the outline"
            : `${slotCount} blanks to fill before sending`
          : "No starter chosen"}
      </div>
    </div>
  );
}

About this pattern

The bridge between a starter chip and a usable prompt. Choosing a template writes its outline into the composer piece by piece — literal wording and blanks alike — with each blank picking up a tinted panel behind it as it lands, so the reader can see both what was inserted for them and what still needs an answer. A caret rides the write head and stops when there is nothing left to add. The words fade and rise a couple of pixels; they never scale, because a template that types itself by growing each word is unreadable while it writes.

Starter prompts above a composerTemplate inserted into a message boxGuided prompt with blanksQuick actions that pre-fill a field

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

    Choosing an action writes a phrased instruction into the input for you.

Related patterns