AI Patterns

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.

JobStart
Seen in

Template · Email

Write a email to about , under .

2 blanks to fill
"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.