Prompt TemplateNew
A prompt written as a sentence with inline blanks — free-text slots that grow as you type and choice slots — that runs once the blanks are filled.
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { ArrowUp, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export type TemplateSlot = {
id: string;
/** Shown inside the empty slot and used as its accessible name ("topic", "audience"). */
placeholder: string;
/** Turns the slot into a choice instead of free text. */
options?: string[];
/** Pre-filled value. */
value?: string;
optional?: boolean;
};
/** A template is text with slots: ["Write a ", { id: "tone", … }, " email about ", …]. */
export type TemplatePart = string | TemplateSlot;
export interface PromptTemplateProps {
title?: string;
parts: TemplatePart[];
/** Called with the filled-in prompt and the slot values. */
onSubmit: (prompt: string, values: Record<string, string>) => void;
submitLabel?: string;
className?: string;
}
const isSlot = (part: TemplatePart): part is TemplateSlot => typeof part !== "string";
export function PromptTemplate({ title, parts, onSubmit, submitLabel = "Run", className }: PromptTemplateProps) {
const slots = parts.filter(isSlot);
const [values, setValues] = React.useState<Record<string, string>>(() =>
Object.fromEntries(slots.map((s) => [s.id, s.value ?? ""]))
);
const [touched, setTouched] = React.useState(false);
const uid = React.useId();
const slotDomId = (slotId: string) => `${uid}-slot-${slotId}`;
const missing = slots.filter((s) => !s.optional && !values[s.id]?.trim());
const prompt = parts
.map((p) => (isSlot(p) ? values[p.id]?.trim() || "" : p))
.join("")
.replace(/\s{2,}/g, " ")
.trim();
function submit() {
setTouched(true);
if (missing.length) {
document.getElementById(slotDomId(missing[0].id))?.focus();
return;
}
onSubmit(prompt, values);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
className={cn("flex w-full flex-col gap-3 rounded-2xl border bg-card p-4 shadow-sm", className)}
>
{title && <p className="text-xs font-medium text-muted-foreground">{title}</p>}
<p className="text-base leading-9 text-pretty">
{parts.map((part, i) =>
isSlot(part) ? (
<Slot
key={part.id}
slot={part}
domId={slotDomId(part.id)}
value={values[part.id] ?? ""}
invalid={touched && !part.optional && !values[part.id]?.trim()}
onChange={(v) => setValues((prev) => ({ ...prev, [part.id]: v }))}
/>
) : (
<span key={i}>{part}</span>
)
)}
</p>
<div className="flex items-center justify-between gap-3 border-t pt-3">
<span aria-live="polite" className="text-xs text-muted-foreground">
{missing.length ? `${missing.length} ${missing.length === 1 ? "blank" : "blanks"} to fill` : "Ready"}
</span>
<button
type="submit"
aria-disabled={missing.length > 0}
className={cn(
"inline-flex items-center gap-1.5 rounded-xl bg-foreground px-3.5 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-90",
missing.length > 0 && "opacity-40"
)}
>
{submitLabel}
<ArrowUp className="size-3.5" aria-hidden />
</button>
</div>
</form>
);
}
function Slot({
slot,
domId,
value,
invalid,
onChange,
}: {
slot: TemplateSlot;
domId: string;
value: string;
invalid: boolean;
onChange: (value: string) => void;
}) {
const filled = value.trim() !== "";
const base = cn(
"mx-0.5 inline-block h-8 rounded-lg border px-2 align-middle text-base leading-none transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
filled ? "border-foreground/20 bg-muted font-medium text-foreground" : "border-dashed border-foreground/30 bg-background text-muted-foreground",
invalid && "border-destructive"
);
if (slot.options) {
return (
<span className="relative inline-block">
<select
id={domId}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={slot.placeholder}
aria-invalid={invalid || undefined}
className={cn(base, "cursor-pointer appearance-none pr-7")}
>
<option value="" disabled>
{slot.placeholder}
</option>
{slot.options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
</span>
);
}
// Free-text slots grow with their content: a hidden sizer sets the width.
return (
<span className="relative inline-grid align-middle">
<span aria-hidden className={cn(base, "invisible col-start-1 row-start-1 whitespace-pre")}>
{value || slot.placeholder}
</span>
<motion.input
id={domId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={slot.placeholder}
aria-label={slot.placeholder}
aria-invalid={invalid || undefined}
animate={invalid ? { x: [0, -3, 3, -2, 0] } : { x: 0 }}
transition={{ duration: 0.3 }}
className={cn(base, "col-start-1 row-start-1 w-full min-w-0 placeholder:text-muted-foreground")}
/>
</span>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Prompt Template
## Summary
A prompt written as a sentence with blanks: fixed text around inline slots that the person fills in — free-text fields that grow as they type, or choice slots with a short list. It gives people a proven prompt structure while leaving the parts that matter to them, and the result is a normal prompt they can see in full.
## When to use
- Repeated tasks with a known shape: emails, product descriptions, job posts, image prompts with style and subject.
- Teams that want consistent output, where the template carries the house structure and people supply the specifics.
- Onboarding, to show what a good prompt looks like while letting people succeed on the first try.
## When not to use
- Open-ended questions and conversation. A template boxes in what should be free.
- When the right details vary wildly from one request to the next — use Clarifying Questions, which can ask about whatever is missing.
- Long forms. More than four or five slots stops reading as a sentence; use a proper form instead.
## Anatomy
- Optional title: which template this is.
- Sentence: fixed text with slots inline, wrapping like prose.
- Empty slot: dashed outline with a placeholder naming what goes there ("recipient", "topic").
- Filled slot: solid, tinted, medium-weight text.
- Choice slot: the same chip with a chevron, opening a native list.
- Footer: how many blanks are left, and the Run button.
## Behavior
- Free-text slots grow with their content, so the sentence keeps reading naturally.
- Tab and Shift+Tab move between slots in reading order.
- Slots can be pre-filled with sensible defaults; optional slots can be left empty and are dropped cleanly from the final prompt.
- Run with a required slot empty marks it, gives it a short shake and moves focus to it, instead of submitting.
- Submitting sends the sentence with the values filled in — exactly what the person sees — plus the slot values for structured use.
## Content guidelines
- Write the fixed text as a natural sentence; read it aloud with example values to check it flows.
- Name slots by what goes in them, in lower case: "audience", not "Enter target audience here".
- Keep choice lists short (two to five) and default to the most common option.
- Offer templates by outcome ("Follow-up email"), not by technique ("Few-shot prompt").
## Accessibility
- Each slot is a real input or native select with an accessible name from its placeholder, so screen readers announce the sentence and its fields in order.
- Empty required slots get `aria-invalid` after a failed Run, and the remaining-blanks count is announced through an `aria-live` region.
- The Run button stays focusable when blanks remain (`aria-disabled`) so it can explain the problem instead of doing nothing.
- Slot states differ by border style (dashed or solid) and weight, not colour alone.
## Related patterns
- **Prompt Bar** — Use it instead for open-ended prompts; offer templates as a starting point from its empty state.
- **Prompt Enhancer** — Use it instead when people would rather type freely and let the AI add the missing detail.
- **Prompt-Driven Onboarding** — Use it as well; templates make good first prompts during onboarding.
- **Clarifying Questions** — Use it instead when the details that matter can't be known in advance.
Do’s and don’ts
Do use it for
- Repeated tasks with a known shape: emails, product descriptions, job posts, image prompts with style and subject.
- Teams that want consistent output, where the template carries the house structure and people supply the specifics.
- Onboarding, to show what a good prompt looks like while letting people succeed on the first try.
Don’t use it for
- Open-ended questions and conversation. A template boxes in what should be free.
- When the right details vary wildly from one request to the next — use Clarifying Questions, which can ask about whatever is missing.
- Long forms. More than four or five slots stops reading as a sentence; use a proper form instead.
Related patterns
Prompt Bar
Use it instead for open-ended prompts; offer templates as a starting point from its empty state.
Prompt Enhancer
Use it instead when people would rather type freely and let the AI add the missing detail.
Prompt-Driven Onboarding
Use it as well; templates make good first prompts during onboarding.
Clarifying Questions
Use it instead when the details that matter can't be known in advance.