Prompt Templates
Searchable prompt template gallery with category filter, variable fill-in form, and onSelect. Install Prompt Templates from the VLLNT UI registry with the shadcn CLI.
When to use this in an AI app
Use Prompt Templates to give users a curated, fillable prompt library instead of a blank box. Variable fields turn a saved prompt into a quick form.
Browse all AI agent componentsSearchable prompt template gallery with category filter, variable fill-in form, and onSelect. Part of the ai family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Prompt Templates to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/prompt-templates.jsonSource
"use client";
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type ReactNode,
useCallback,
useId,
useMemo,
useState,
} from "react";
import { Search, Sparkles } from "lucide-react";
import { cn } from "../../lib/utils";
import { Badge } from "../badge/badge";
import { Button } from "../button/button";
import { Input } from "../input/input";
const VARIABLE_PATTERN = /{{\s*([\w-]+)\s*}}/g;
const ALL_CATEGORY_VALUE = "__all__";
/**
* One prompt template entry.
*
* @public
*/
export type PromptTemplate = {
/** Optional category (matched against {@link PromptTemplateCategory.name}). */
category?: string;
/** Sub-headline shown under the title. */
description?: ReactNode;
/** Stable identifier. */
id: string;
/**
* Raw template body with `{{variable}}` placeholders. Placeholders are
* detected automatically; the explicit `variables` array overrides
* detection (useful when the same placeholder appears more than once).
*/
template: string;
/** Display title. */
title: ReactNode;
/** Override for detected variable names. */
variables?: string[];
};
/**
* Category chip for filtering.
*
* @public
*/
export type PromptTemplateCategory = {
/** Optional icon rendered next to the name. */
icon?: ReactNode;
/** Category display name (matched against {@link PromptTemplate.category}). */
name: string;
};
/**
* Localizable strings.
*
* @public
*/
export type PromptTemplatesLabels = {
/** Caption for the all-categories chip. Defaults to `"All"`. */
allCategory?: string;
/** Caption for the cancel button on the variable form. Defaults to `"Cancel"`. */
cancel?: string;
/** Empty-state heading when no templates match. Defaults to `"No prompts found."`. */
empty?: string;
/** Caption for the use button when filling in variables. Defaults to `"Insert"`. */
insert?: string;
/** Search input placeholder. Defaults to `"Search prompts…"`. */
searchPlaceholder?: string;
/** Caption for the use button on a card. Defaults to `"Use template"`. */
use?: string;
/** Caption above the variable form. Defaults to `"Fill in the placeholders"`. */
variablesHeading?: string;
};
const DEFAULT_LABELS = {
allCategory: "All",
cancel: "Cancel",
empty: "No prompts found.",
insert: "Insert",
searchPlaceholder: "Search prompts…",
use: "Use template",
variablesHeading: "Fill in the placeholders",
} as const satisfies Required<PromptTemplatesLabels>;
/**
* Props for {@link PromptTemplates}.
*
* @public
*/
export type PromptTemplatesProps = {
/** Optional list of categories to render as filter chips. */
categories?: PromptTemplateCategory[];
/** Localizable strings. */
labels?: PromptTemplatesLabels;
/**
* Fires with the resolved template body. When the template has variables,
* the user fills them in first; otherwise this fires on click.
*/
onSelect?: (resolved: string, template: PromptTemplate) => void;
/** The template list. */
templates: PromptTemplate[];
} & ComponentPropsWithoutRef<"section">;
function detectVariables(template: PromptTemplate): string[] {
if (template.variables) return template.variables;
const matches = [...template.template.matchAll(VARIABLE_PATTERN)];
const seen = new Set<string>();
return matches.reduce<string[]>((accumulator, match) => {
const name = match[1];
if (name && !seen.has(name)) {
seen.add(name);
accumulator.push(name);
}
return accumulator;
}, []);
}
function fillTemplate(
template: string,
values: Record<string, string>,
): string {
return template.replaceAll(
VARIABLE_PATTERN,
(_match: string, name: string) => values[name] ?? `{{${name}}}`,
);
}
function matchesCategory(template: PromptTemplate, selected: string): boolean {
if (selected === ALL_CATEGORY_VALUE) return true;
return template.category === selected;
}
function matchesQuery(template: PromptTemplate, query: string): boolean {
if (!query) return true;
const lowered = query.toLowerCase();
const title =
typeof template.title === "string" ? template.title.toLowerCase() : "";
const description =
typeof template.description === "string"
? template.description.toLowerCase()
: "";
return title.includes(lowered) || description.includes(lowered);
}
type FilterBarProps = {
categories: PromptTemplateCategory[];
labels: Required<PromptTemplatesLabels>;
onCategoryChange: (value: string) => void;
onQueryChange: (value: string) => void;
query: string;
searchId: string;
selectedCategory: string;
};
function FilterBar({
categories,
labels,
onCategoryChange,
onQueryChange,
query,
searchId,
selectedCategory,
}: FilterBarProps): ReactNode {
const handleSearch = (event: ChangeEvent<HTMLInputElement>): void => {
onQueryChange(event.target.value);
};
return (
<div className="flex flex-col gap-3">
<div className="relative">
<Search
aria-hidden="true"
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label={labels.searchPlaceholder}
className="pl-9"
id={searchId}
onChange={handleSearch}
placeholder={labels.searchPlaceholder}
type="search"
value={query}
/>
</div>
{categories.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
<CategoryChip
active={selectedCategory === ALL_CATEGORY_VALUE}
label={labels.allCategory}
onClick={() => {
onCategoryChange(ALL_CATEGORY_VALUE);
}}
value={ALL_CATEGORY_VALUE}
/>
{categories.map((category) => (
<CategoryChip
active={selectedCategory === category.name}
icon={category.icon}
key={category.name}
label={category.name}
onClick={() => {
onCategoryChange(category.name);
}}
value={category.name}
/>
))}
</div>
) : null}
</div>
);
}
type CategoryChipProps = {
active: boolean;
icon?: ReactNode;
label: string;
onClick: () => void;
value: string;
};
function CategoryChip({
active,
icon,
label,
onClick,
value,
}: CategoryChipProps): ReactNode {
return (
<button
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent",
)}
data-value={value}
onClick={onClick}
type="button"
>
{icon ? (
<span aria-hidden="true" className="[&>svg]:h-3.5 [&>svg]:w-3.5">
{icon}
</span>
) : null}
{label}
</button>
);
}
type CardProps = {
active: boolean;
labels: Required<PromptTemplatesLabels>;
onActivate: (template: PromptTemplate) => void;
onCancel: () => void;
onResolve: (resolved: string, template: PromptTemplate) => void;
onValueChange: (name: string, value: string) => void;
template: PromptTemplate;
values: Record<string, string>;
};
type CardHeaderProps = {
category?: string;
description?: ReactNode;
title: ReactNode;
};
function CardHeader({
category,
description,
title,
}: CardHeaderProps): ReactNode {
return (
<header className="flex flex-col gap-1">
<h4 className="text-sm font-semibold tracking-tight text-foreground">
{title}
</h4>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
{category ? (
<Badge className="self-start" variant="outline">
{category}
</Badge>
) : null}
</header>
);
}
type CardActionsProps = {
onUse: () => void;
useLabel: string;
variableCount: number;
};
function CardActions({
onUse,
useLabel,
variableCount,
}: CardActionsProps): ReactNode {
return (
<div className="flex items-center justify-between gap-2">
{variableCount > 0 ? (
<span className="text-xs text-muted-foreground">
{variableCount.toString()} variable{variableCount === 1 ? "" : "s"}
</span>
) : (
<span aria-hidden="true" />
)}
<Button
onClick={onUse}
size="sm"
type="button"
variant={variableCount > 0 ? "outline" : "default"}
>
<Sparkles aria-hidden="true" className="mr-2 size-3.5" />
{useLabel}
</Button>
</div>
);
}
function PromptTemplateCard({
active,
labels,
onActivate,
onCancel,
onResolve,
onValueChange,
template,
values,
}: CardProps): ReactNode {
const variables = detectVariables(template);
const handleUse = useCallback(() => {
if (variables.length === 0) {
onResolve(template.template, template);
return;
}
if (active) {
onResolve(fillTemplate(template.template, values), template);
return;
}
onActivate(template);
}, [active, onActivate, onResolve, template, values, variables.length]);
return (
<article
className="flex flex-col gap-3 rounded-xl border border-border bg-background p-4 shadow-sm"
data-template-id={template.id}
>
<CardHeader
category={template.category}
description={template.description}
title={template.title}
/>
{active && variables.length > 0 ? (
<VariableForm
fieldIdPrefix={template.id}
labels={labels}
onCancel={onCancel}
onSubmit={handleUse}
onValueChange={onValueChange}
values={values}
variables={variables}
/>
) : (
<CardActions
onUse={handleUse}
useLabel={labels.use}
variableCount={variables.length}
/>
)}
</article>
);
}
type VariableFormProps = {
fieldIdPrefix: string;
labels: Required<PromptTemplatesLabels>;
onCancel: () => void;
onSubmit: () => void;
onValueChange: (name: string, value: string) => void;
values: Record<string, string>;
variables: string[];
};
type VariableFieldProps = {
fieldId: string;
name: string;
onValueChange: (name: string, value: string) => void;
value: string;
};
function VariableField({
fieldId,
name,
onValueChange,
value,
}: VariableFieldProps): ReactNode {
const handleVariableValueChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onValueChange(name, event.target.value);
},
[name, onValueChange],
);
return (
<div className="flex flex-col gap-1 text-xs">
<label className="font-medium text-foreground" htmlFor={fieldId}>
{name}
</label>
<Input
id={fieldId}
onChange={handleVariableValueChange}
placeholder={`Value for ${name}`}
value={value}
/>
</div>
);
}
function VariableForm({
fieldIdPrefix,
labels,
onCancel,
onSubmit,
onValueChange,
values,
variables,
}: VariableFormProps): ReactNode {
return (
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-border bg-muted/30 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{labels.variablesHeading}
</p>
<div className="flex flex-col gap-2">
{variables.map((name) => (
<VariableField
fieldId={`${fieldIdPrefix}-${name}`}
key={name}
name={name}
onValueChange={onValueChange}
value={values[name] ?? ""}
/>
))}
</div>
<div className="mt-1 flex justify-end gap-2">
<Button onClick={onCancel} size="sm" type="button" variant="ghost">
{labels.cancel}
</Button>
<Button onClick={onSubmit} size="sm" type="button">
{labels.insert}
</Button>
</div>
</div>
);
}
/**
* Library / gallery of saved prompt templates with search, category filter,
* and a built-in fill-in form for `{{variable}}` placeholders. Composes
* existing {@link Input}, {@link Button}, and {@link Badge} primitives.
*
* @example
* ```tsx
* <PromptTemplates
* templates={[
* {
* id: "code-review",
* title: "Code Review",
* description: "Review code for bugs and improvements",
* template: "Review this {{language}} code:\n\n{{code}}",
* category: "Code",
* },
* ]}
* categories={[{ name: "Code" }, { name: "Writing" }]}
* onSelect={(resolved) => insertIntoComposer(resolved)}
* />
* ```
*
* @public
*/
type ControllerState = {
activeId: null | string;
filtered: PromptTemplate[];
handleActivate: (template: PromptTemplate) => void;
handleCancel: () => void;
handleCategoryChange: (value: string) => void;
handleQueryChange: (value: string) => void;
handleResolve: (resolved: string, template: PromptTemplate) => void;
handleValueChange: (name: string, value: string) => void;
query: string;
searchId: string;
selectedCategory: string;
values: Record<string, string>;
};
function usePromptTemplatesController(
templates: PromptTemplate[],
onSelect: PromptTemplatesProps["onSelect"],
): ControllerState {
const searchId = useId();
const [query, setQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY_VALUE);
const [activeId, setActiveId] = useState<null | string>(null);
const [values, setValues] = useState<Record<string, string>>({});
const filtered = useMemo(
() =>
templates.filter(
(template) =>
matchesCategory(template, selectedCategory) &&
matchesQuery(template, query),
),
[query, selectedCategory, templates],
);
const handleActivate = useCallback((template: PromptTemplate) => {
setActiveId(template.id);
setValues({});
}, []);
const handleCancel = useCallback(() => {
setActiveId(null);
setValues({});
}, []);
const handleResolve = useCallback(
(resolved: string, template: PromptTemplate) => {
onSelect?.(resolved, template);
setActiveId(null);
setValues({});
},
[onSelect],
);
const handleValueChange = useCallback((name: string, value: string) => {
setValues((current) => ({ ...current, [name]: value }));
}, []);
return {
activeId,
filtered,
handleActivate,
handleCancel,
handleCategoryChange: setSelectedCategory,
handleQueryChange: setQuery,
handleResolve,
handleValueChange,
query,
searchId,
selectedCategory,
values,
};
}
type GridProps = {
controller: ControllerState;
labels: Required<PromptTemplatesLabels>;
};
function TemplateGrid({ controller, labels }: GridProps): ReactNode {
if (controller.filtered.length === 0) {
return (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
{labels.empty}
</p>
);
}
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{controller.filtered.map((template) => (
<PromptTemplateCard
active={controller.activeId === template.id}
key={template.id}
labels={labels}
onActivate={controller.handleActivate}
onCancel={controller.handleCancel}
onResolve={controller.handleResolve}
onValueChange={controller.handleValueChange}
template={template}
values={controller.values}
/>
))}
</div>
);
}
export const PromptTemplates = ({
ref,
...props
}: PromptTemplatesProps & { ref?: React.Ref<HTMLElement> }) => {
const { categories, className, labels, onSelect, templates, ...rest } = props;
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const controller = usePromptTemplatesController(templates, onSelect);
return (
<section
className={cn(
"flex flex-col gap-4 rounded-2xl border bg-background p-4",
className,
)}
ref={ref}
{...rest}
>
<FilterBar
categories={categories ?? []}
labels={resolvedLabels}
onCategoryChange={controller.handleCategoryChange}
onQueryChange={controller.handleQueryChange}
query={controller.query}
searchId={controller.searchId}
selectedCategory={controller.selectedCategory}
/>
<TemplateGrid controller={controller} labels={resolvedLabels} />
</section>
);
};
PromptTemplates.displayName = "PromptTemplates";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.