Prompt Templates

Searchable prompt template gallery in React: category filter, variable fill-in form, and onSelect. Ship a reusable prompt library. Install via the shadcn CLI.

Report a bug

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 components

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/prompt-templates.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ChangeEvent, type ComponentPropsWithoutRef, forwardRef, 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" role="tablist"> <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-selected={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} role="tab" 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" role="tabpanel" > {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 = forwardRef<HTMLElement, PromptTemplatesProps>( (props, ref) => { 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";

Dependencies

  • @vllnt/ui@^0.2.1
  • lucide-react