Prompt Template Fill
Picking a starter writes its outline into the composer with the blanks lighting up.
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 { 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Choosing an action writes a phrased instruction into the input for you.
Related patterns
- Prompt Submit LiftThe composed prompt lifts out of the field and docks as a sent message while the thread makes room.
- Context Pill AttachA dragged file is caught by the prompt bar and lands inside it as a context pill.
- Suggestion Chips CascadeSuggested next actions land in quick succession under the answer, one chip after another.