AI

Prompt Variables

An inline template filler — every {{variable}} slot becomes a chip-sized text, select or number editor inside the sentence, repeats mirror the first one live, and the preview is byte-identical to what gets sent.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronDown, Copy, CornerDownLeft } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Template grammar
 *
 * `{{name}}` is a slot. The name is everything between the braces, trimmed, so
 * `{{ tone }}` and `{{tone}}` are the same variable. A name is letters (any
 * script), digits, `_`, `.` or `-` — deliberately strict, because a lenient
 * "anything without braces" rule turns a pasted JSON body like `{"a": 1}` into a
 * variable called `"a": 1` and silently eats that text on send.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prompt-variables.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PromptVariables" component: a prompt
template whose {{variable}} slots are edited IN PLACE, inside the sentence,
instead of in a side form. Only npm dependency: lucide-react.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
  children and onChange. The root is role="group" with aria-label={label}.
- template: string — the prompt body. `{{name}}` marks a slot.
- variables?: PromptVariableSpec[] — metadata ABOUT the slots, never the list of
  them: { name, label?, type?: "text" | "number" | "select", placeholder?,
  required? (default true), defaultValue?, options? (select only),
  min? / max? / step? (number only) }. A spec whose name never appears in the
  template is ignored: the template is the only thing that can put an editor on
  screen, so the two can never disagree.
- values?: Record<string, string> together with onValuesChange?(next) — the
  controlled pair; omit `values` for uncontrolled. Every value is a string,
  numbers included, because the payload is text.
- onComplete?(filled: string, values) — omit it and no button renders at all
  (no dead affordance). completeLabel?, label?, previewLabel?.
- showPreview?: boolean (default true), validate?: "submit" | "blur" (default)
  | "always", disabled?: boolean.
- Also export parsePromptTemplate(template): Segment[] and
  renderPromptTemplate(segments, values): string, so the same string can be
  rebuilt anywhere else in the app.

Behavior — grammar (parse once, over the template only)
- Compile the template to segments: { kind: "text", value } | { kind: "slot",
  name } in one left-to-right pass, no regex over the whole body.
- A name is trimmed (`{{ tone }}` === `{{tone}}`) and must match
  /^[\p{L}\p{N}_.-]+$/u with length <= 64 — letters of any script, so `{{语言}}`
  works. Strictness is the point: a lenient "anything without braces" rule turns
  a pasted JSON body like {"a": 1} into a variable named `"a": 1` and eats that
  text on send.
- `{{{{` renders one literal `{{` and consumes all four characters, so the
  second pair can never be re-read as an opener. Doubling (printf's %%) beats a
  backslash escape because prompt bodies are full of regexes, Windows paths and
  LaTeX that a backslash rule would force authors to escape too. `}}` needs no
  escape — it only means anything after an unescaped `{{`.
- Any `{{` that does not open a well-formed slot is literal text, never an
  error: unclosed (`{{oops`), empty, brace-containing, over-long.
- Values are dropped into the holes and NEVER re-scanned. One substitution pass
  means a typed value containing `{{other}}` stays inert text; there is no way
  for user input to change the template's structure.

Behavior — slots and the tab order
- Resolve variables in first-appearance order (reading order), counting
  occurrences. The FIRST occurrence of a name owns the editor; every later
  occurrence renders a static mirror chip that shows the same value live.
  Making every occurrence an editor would put one variable in the tab order
  twice and leave the reader guessing which copy is authoritative.
- Tab / Shift+Tab therefore visits exactly one stop per variable, in reading
  order. Mirrors are not focusable and are not buttons.
- Enter inside a text/number slot means "go to the next slot that still needs
  something", wrapping once past the end; if nothing needs anything it triggers
  completion. It must never submit a surrounding form — call preventDefault.
- Cmd/Ctrl+Enter anywhere in the group attempts completion.
- Ignore key handling while event.nativeEvent.isComposing (IME).

Behavior — the three editors
- text: an <input type="text"> that measures itself (see Rendering). aria-label
  is the variable's label; the placeholder falls back to the variable name, so
  an empty chip literally reads as the hole it fills.
- number: same input with inputMode="decimal", but a keystroke that cannot
  become a number is REJECTED rather than accepted-then-flagged (accept
  /^-?\d*(?:\.\d*)?$/ so a lone "-" and a half-typed decimal survive).
  ArrowUp/ArrowDown step by `step`, clamped to min/max, starting from min when
  blank; round the result (toFixed(6) then Number) so 0.1 steps do not drift
  into 0.30000000000000004.
- select: a select-only combobox (APG), NOT a native <select> and not a
  role="menu" dropdown — a button with role="combobox", aria-expanded,
  aria-controls and aria-activedescendant, plus a <ul role="listbox"> of
  <li role="option" aria-selected>. DOM focus stays on the button the whole
  time, which is what keeps "one tab stop per variable" true.
  - Closed: ArrowUp/ArrowDown/Enter/Space open at the selected option; a
    printable character selects the next option starting with it (native select
    behaviour).
  - Open: ArrowUp/ArrowDown/Home/End move the active option, Enter/Space commit
    and return focus to the button, Escape closes with no change and
    stopPropagation (so a dialog wrapping the panel does not close too), Tab
    closes and lets focus move on.
  - Typeahead buffer: append while keystrokes are <600ms apart, otherwise start
    fresh — store the timestamp in a ref instead of running a reset timer, so
    there is nothing to cancel on unmount.
  - The listbox preventDefaults pointerdown so clicking an option cannot blur
    the combobox; a document pointerdown listener closes it on an outside
    click and is removed when the popup closes or the component unmounts.
  - An OPTIONAL select gets one extra "— leave blank —" entry; a required one
    must not offer a way back to empty.

Behavior — validation timing and completion
- Compute one issue per slot: "blank" (required and whitespace-only),
  "not-a-number", "out-of-range", "not-an-option". Optional blanks are not
  issues.
- WHEN a slot turns destructive is a separate decision from whether it has an
  issue: validate="blur" (default) flags a slot the reader has already left or
  after a blocked completion, "submit" waits for that blocked completion,
  "always" flags from first paint — use it when the server just rejected the
  payload.
- The completion button is blocked, not hidden: it stays enabled while blanks
  remain (only `disabled` disables it). Pressing it marks the whole form
  submitted, flags everything, focuses+selects the first blocking slot, shakes
  the flagged chips once, and names them in the status line
  ("2 to go — audience still blank, words is out of range").
- Only when nothing blocks does it call onComplete(filled, values) with the
  complete value map, defaults included.

Behavior — preview and copy
- The preview renders the same segment list: filled slots contribute their
  value, an unfilled OPTIONAL slot contributes nothing (that is genuinely the
  payload), an unfilled REQUIRED slot shows as a destructive `{{name}}` mark and
  copying is disabled — so the preview can never be mistaken for a sendable
  string, and the moment the last blank is filled it is byte-identical to what
  onComplete receives.
- Copy uses navigator.clipboard, guarded for insecure contexts, and reverts
  after ~1.6s. Key the "copied" flag on the copied TEXT, not a boolean, so an
  edit silently invalidates it; clear the timeout on unmount and skip the state
  update if the component died while the promise was in flight.

Behavior — state
- Rebuild the authoritative value map from the resolved variables on every
  render: values[name] ?? spec.defaultValue ?? "". Swapping the template then
  drops stale keys and picks up new defaults with no effect and no stale-state
  bug, and a caller-supplied map never has to be complete.
- Uncontrolled mode stores that same complete map, so onValuesChange always
  emits a full object either way.

Rendering & styling
- Semantic tokens only: bg-card, bg-muted, bg-muted/40, bg-popover /
  text-popover-foreground, bg-accent / text-accent-foreground, bg-primary /
  text-primary-foreground, bg-primary/10 + border-primary/40 for a filled chip,
  bg-destructive/10 + border-destructive + text-destructive for a flagged one,
  border, ring, text-muted-foreground. No hex, no rgb(), no oklch().
- A chip measures ITSELF: an inline-grid wrapper holds an invisible twin span
  (same font, same padding, whitespace-pre, aria-hidden) and the input in the
  same grid cell (col-start-1 row-start-1, w-full min-w-0). The twin sizes the
  track, so the chip grows and shrinks with its own value — no ResizeObserver,
  no measurement effect, nothing to clean up.
- Chips are align-middle, not baseline-aligned: the text chip clips the twin
  with overflow-hidden, and an inline-level box with non-visible overflow takes
  its margin edge as the baseline, which would drop every chip below its line.
  The select chip must NOT be overflow-hidden or it would clip its own popup.
- The template box keeps whitespace-pre-wrap (templates are multi-line),
  leading-8 (chips need vertical room), wrap-anywhere and min-w-0.
- Empty chip = dashed border + muted text; filled = solid primary tint; flagged
  = solid destructive. The dashed/solid switch, not colour alone, carries
  "still a hole".
- Motion: colour transitions plus a 0.2s shake on flagged chips, both
  motion-reduce-neutralised; under prefers-reduced-motion the shake is never
  even started (matchMedia via useSyncExternalStore), and blocking still
  focuses, flags and announces. Keyframes ship in a React 19 hoisted
  <style href precedence> — no Tailwind config edit.
- Accessibility: aria-invalid + aria-required on the editors, focus-within ring
  on the chip (the ring must be on the wrapper, since the input has no border of
  its own), one role="status" aria-live="polite" line carrying the blocked
  message, and a "3/6 filled" counter in the header.

Customization levers
- Editor types: `text | number | select` is a switch on one union — add
  "date" or "textarea" by adding a branch plus its issue rule; nothing else in
  the pipeline knows about types.
- Validation timing: the `validate` prop is the whole policy. Add "change" for
  a stricter form, or hard-disable the button instead of blocking it if your
  product prefers silence over an explanation.
- Repeat policy: mirrors are static by design; if you want every occurrence
  editable, drop the first-occurrence check — and accept N tab stops per
  variable.
- Chrome: showPreview={false} leaves just the sentence; drop the header counter,
  the copy button or the status line independently — each is one JSX block.
- Chip look: swap the primary tint for `bg-secondary`, or make unfilled chips an
  underline instead of a dashed box (border-b-2 border-dashed, no background)
  for a lighter, document-like feel.
- Density: the template box's leading-8 and the chips' px-1.5 py-0.5 are the
  only spacing knobs; text-[0.9375em] keeps chips proportional to whatever font
  size the surrounding prose uses.
- Payload shape: renderPromptTemplate is pure — wrap it to emit JSON, to escape
  the values, or to send { template, values } to the server instead of the
  flattened string.

Concepts

  • Inline slot editing — the editors live inside the sentence instead of in a form beside it, so the reader judges the wording and the values together; the cost is that each editor must be chip-sized, self-measuring and safe to sit in a line of wrapping prose.
  • First-occurrence ownership — a name that appears three times gets exactly one editor, at its first appearance; the later occurrences are mirrors that update live, which keeps the tab order at one stop per variable and removes any doubt about which copy is authoritative.
  • Self-measuring chip — an invisible twin of the value shares the chip's single grid cell and sizes it, so the hole grows and shrinks as you type without a ResizeObserver, a measurement effect or a layout pass to clean up.
  • Deferred flagging — having an issue and showing it are separate: a blank is quietly a blank until you leave it (blur), until you press the button (submit), or from the first paint (always, for a payload the server already rejected). Painting a fresh template all red is hostile.
  • Blocked, not hidden — the completion button stays enabled while blanks remain; pressing it answers by flagging, shaking, focusing the first blocker and naming it in a polite live region, instead of going grey and explaining nothing.
  • Preview equals payload — one substitution pass, values never re-scanned: a value containing {{other}} stays literal text, and once no required blank is left the preview string is character-for-character what onComplete hands over.

On This Page