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