Browse all components
Prompt Editor UI
A prompt template editor with {{variables}} highlighted as you type, a rendered preview, and a line diff against the previous version.
Preview
Presets
Props
Agent promptClaude Code · Cursor · any agent
Add the Prompt Editor UI component from the Scrim UI registry to this project and use it in the configuration described below.
Prompt Editor UI — A prompt template editor with {{variables}} highlighted as you type, a rendered preview, and a line diff against the previous version.
## 1. Install
```bash
npx shadcn@latest add @scrimui/prompt-editor
```
This writes a single file to `components/ui/prompt-editor.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/prompt-editor to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { PromptEditor } from "@/components/ui/prompt-editor";
// The variables this deployment actually fills. Anything else matching
// {{...}} gets the warning tint and is left as-is by renderTemplate.
const variables = ["product","audience","tone","limit"];
const [template, setTemplate] = React.useState(`You are the docs writer for {{product}}.
Answer for {{audience}} in a {{tone}} tone, under {{limit}} words.`);
const previewValues = {
"product": "Scrim UI",
"audience": "a senior frontend engineer",
"tone": "direct",
"limit": "120"
};
<PromptEditor
value={template}
onChange={setTemplate}
variables={variables}
previewValues={previewValues}
/>
```
## 3. The configuration that matters
This is the component's "Editing" state — Known variables in green, a Write/Preview toggle, and a live count. The default shape.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
Reference: https://scrimui.dev/components/prompt-editorFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/prompt-editorComponent source
React + Tailwind component using shadcn/ui (button, card, textarea). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add button card textarea"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import * as React from "react";
/**
* A prompt template editor: {{variables}} highlighted as you type, a rendered
* preview, and a line diff against a previous version.
*
* The highlighting is a transparent textarea over a highlighted <pre>. The
* trick is well known; the traps are in keeping the two layers pixel-identical:
*
* 1. ANY TYPOGRAPHIC DRIFT DESYNCS THE LAYERS. Font, size, line height,
* padding and wrapping must match exactly — a textarea's default font is
* not the pre's, so both get the same classes, and the shared values live
* in one constant so they cannot drift apart in two places.
* 2. THE TEXTAREA SCROLLS, THE PRE DOES NOT. Without scroll sync the
* highlights stay behind while the text moves. The pre's scrollTop is
* mirrored from the textarea's onScroll — an event handler, so touching
* the ref there is safe.
* 3. A TRAILING NEWLINE COLLAPSES IN THE PRE BUT NOT THE TEXTAREA. The pre
* renders one line short and the caret appears to float. A zero-width
* space is appended to the mirror text so both layers keep the last line.
* 4. UNRESOLVED VARIABLES MUST LOOK DIFFERENT FROM KNOWN ONES. A typo in a
* {{name}} is silent in plain text and loud in preview. Known variables
* get one tint, anything else matching {{...}} gets a warning tint, and
* `renderTemplate` leaves unknown names untouched rather than blanking
* them — "{{naem}}" in the output beats an empty hole.
*/
/* ------------------------------------------------------------------ */
/* Template rendering */
/* ------------------------------------------------------------------ */
const VARIABLE_PATTERN = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
/** The variable names a template references, in order of first appearance. */
export function templateVariables(template: string): string[] {
const names: string[] = [];
for (const match of template.matchAll(VARIABLE_PATTERN)) {
if (!names.includes(match[1])) names.push(match[1]);
}
return names;
}
/**
* Substitute {{name}} from `values`. A name with no value is left as-is —
* "{{naem}}" in the output is a visible mistake; an empty string where a name
* should be is an invisible one.
*/
export function renderTemplate(template: string, values: Record<string, string>): string {
return template.replace(VARIABLE_PATTERN, (raw, name: string) =>
Object.prototype.hasOwnProperty.call(values, name) ? values[name] : raw,
);
}
/* ------------------------------------------------------------------ */
/* Line diff (for compareWith) */
/* ------------------------------------------------------------------ */
type DiffLine = { text: string; side: "same" | "removed" | "added" };
/** LCS over lines. Prompts are short; the quadratic table is never felt. */
function diffLines(before: string, after: string): DiffLine[] {
const a = before.replace(/\n$/, "").split("\n");
const b = after.replace(/\n$/, "").split("\n");
const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
}
}
const lines: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
lines.push({ text: a[i], side: "same" });
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) {
lines.push({ text: a[i++], side: "removed" });
} else {
lines.push({ text: b[j++], side: "added" });
}
}
while (i < a.length) lines.push({ text: a[i++], side: "removed" });
while (j < b.length) lines.push({ text: b[j++], side: "added" });
return lines;
}
/* ------------------------------------------------------------------ */
/* Highlight layer */
/* ------------------------------------------------------------------ */
type Token = { text: string; variable: string | null; known: boolean };
function tokenize(template: string, known: string[] | undefined): Token[] {
const tokens: Token[] = [];
let last = 0;
for (const match of template.matchAll(VARIABLE_PATTERN)) {
if (match.index > last) tokens.push({ text: template.slice(last, match.index), variable: null, known: false });
tokens.push({
text: match[0],
variable: match[1],
known: known === undefined || known.includes(match[1]),
});
last = match.index + match[0].length;
}
if (last < template.length) tokens.push({ text: template.slice(last), variable: null, known: false });
return tokens;
}
/* Shared by both layers — the whole trick fails if these drift. */
const LAYER_CLASSES =
"m-0 whitespace-pre-wrap break-words p-3 font-mono text-base sm:text-sm leading-6";
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export type PromptEditorProps = {
value: string;
onChange: (value: string) => void;
/** Known variable names. Any {{other}} gets a warning tint. Omit to accept everything. */
variables?: string[];
placeholder?: string;
rows?: number;
/** Sample values. When provided, a Write / Preview toggle appears. */
previewValues?: Record<string, string>;
/** A previous version of the template; renders a line diff below the editor. */
compareWith?: string;
className?: string;
};
export function PromptEditor({
value,
onChange,
variables,
placeholder = "Write the prompt… {{variables}} are highlighted as you type.",
rows = 8,
previewValues,
compareWith,
className = "",
}: PromptEditorProps) {
const [tab, setTab] = React.useState<"write" | "preview">("write");
const mirrorRef = React.useRef<HTMLPreElement>(null);
const tokens = tokenize(value, variables);
const used = templateVariables(value);
const unknown = variables ? used.filter((n) => !variables.includes(n)) : [];
const diff = React.useMemo(
() => (compareWith !== undefined && compareWith !== value ? diffLines(compareWith, value) : null),
[compareWith, value],
);
return (
<Card className={`gap-0 py-0 overflow-hidden rounded-xl border border-border bg-card ${className}`}>
{previewValues !== undefined && (
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
{(["write", "preview"] as const).map((t) => (
<Button variant="ghost" size="sm"
key={t}
type="button"
onClick={() => setTab(t)}
aria-pressed={tab === t}
className={`h-8 rounded-md px-2.5 text-xs font-medium capitalize transition-colors ${
tab === t
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted"
}`}
>
{t}
</Button>
))}
<span className="ml-auto text-xs text-muted-foreground">
{used.length} {used.length === 1 ? "variable" : "variables"} · {value.length} chars
</span>
</div>
)}
{tab === "preview" && previewValues !== undefined ? (
<div
className={`${LAYER_CLASSES} text-foreground`}
style={{ minHeight: `${rows * 1.5 + 1.6}rem` }}
>
{renderTemplate(value, previewValues) || <span className="text-muted-foreground">Nothing to preview.</span>}
</div>
) : (
<div className="relative">
{/* Highlight mirror. aria-hidden: the textarea carries the text. */}
<pre ref={mirrorRef} aria-hidden className={`${LAYER_CLASSES} pointer-events-none absolute inset-0 overflow-hidden text-foreground`}>
{tokens.map((token, i) =>
token.variable === null ? (
<React.Fragment key={i}>{token.text}</React.Fragment>
) : (
<span
key={i}
className={
token.known
? "rounded-[3px] bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-300"
: "rounded-[3px] bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-300"
}
>
{token.text}
</span>
),
)}
{/* A trailing newline collapses in the pre but not the textarea —
the zero-width space keeps the last line in both layers. */}
{"\u200B"}
</pre>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={(e) => {
const mirror = mirrorRef.current;
if (mirror) {
mirror.scrollTop = e.currentTarget.scrollTop;
mirror.scrollLeft = e.currentTarget.scrollLeft;
}
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className={`border-0 rounded-none shadow-none focus-visible:ring-0 field-sizing-fixed dark:bg-transparent ${LAYER_CLASSES} relative block w-full resize-y bg-transparent text-transparent caret-foreground outline-none placeholder:text-muted-foreground `}
/>
</div>
)}
{unknown.length > 0 && tab === "write" && (
<p className="border-t border-amber-100 bg-amber-50 px-3 py-1.5 text-xs text-amber-800 dark:border-amber-950 dark:bg-amber-950/40 dark:text-amber-300">
{unknown.length === 1 ? "Unknown variable" : "Unknown variables"}: {unknown.map((n) => `{{${n}}}`).join(", ")} — not in the variables list, left as-is in preview.
</p>
)}
{diff !== null && (
<div className="border-t border-border">
<p className="px-3 pt-2 text-xs font-medium text-muted-foreground">
Against the previous version
</p>
<div className="py-1.5 font-mono text-xs leading-5">
{diff.map((line, i) =>
line.side === "same" ? (
<div key={i} className="truncate px-3 text-muted-foreground">
{" "}
{line.text}
</div>
) : (
<div
key={i}
className={`truncate px-3 ${
line.side === "removed"
? "bg-red-50 text-red-800 dark:bg-red-950/40 dark:text-red-300"
: "bg-emerald-50 text-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200"
}`}
>
{line.side === "removed" ? "− " : "+ "}
{line.text}
</div>
),
)}
</div>
</div>
)}
</Card>
);
}
When to use it
- Pass the list of variables your deployment actually fills. It is what turns a typo'd {{name}} from a silent hole in the output into an amber highlight with a warning line.
- Fill the preview with realistic sample values, not lorem ipsum — a preview that reads like the real prompt is the one reviewers actually check before saving.
- Use `renderTemplate` for the substitution at send time too, so the preview and the request can never disagree about how a variable resolves.
- Pass `compareWith` when editing a versioned prompt. The diff is the answer to the only question a reviewer has: what did this change actually do.
- Give the editor room. A system prompt is the longest text in the product, and a three-line box invites the kind of edit that never gets reviewed.
What breaks in production
- Highlighting with a rich-text editor. A contentEditable with inline spans fights the browser's own editing behavior; the transparent-textarea-over-pre split keeps the browser's textarea and adds color underneath it.
- Letting the two layers' typography drift. Font, size, line height, padding and wrapping must match exactly — the shared class constant in the source is load-bearing, not tidiness.
- Forgetting scroll sync. The textarea scrolls and the pre does not, so without mirroring scrollTop the highlights stay behind while the text moves.
- Substituting unknown variables with an empty string. '{{naem}}' in the output is a visible mistake; a gap where a name should be is an invisible one that ships.
- Diffing prompts by eye in a PR. Two long strings in a code review hide a one-word change; compareWith puts the changed lines under the editor where the edit happens.
Related Components
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.
A chat input that accepts file uploads — attachment chips with upload progress, type icons, and one-click removal.
A chat input with the model picker built in — switch models inline, with badges for what each one can do.
Render an AI reply token by token like ChatGPT's typing effect — blinking cursor, stop button, and no layout jump as the text grows.