Building a Self-Hosted Prompt Library UI with TailwindCSS and Radix Dialog
Building a Self-Hosted Prompt Library UI with TailwindCSS and Radix Dialog When evaluating f/prompts.chat for internal deployment, the first friction point wasn't the backend—it was the UI layer for browsing, searching, and injecting prompts into existing AI workflows. The repository offers a clean
When evaluating f/prompts.chat for internal deployment, the first friction point wasn't the backend—it was the UI layer for browsing, searching, and injecting prompts into existing AI workflows. The repository offers a clean prompt-sharing architecture, but integrating it into a production developer portal requires solving three specific UI problems:
Our initial prototype used a naive approach—toggling display: none on a modal wrapper and manually managing focus with document.querySelector. This broke immediately:
// Anti-pattern: breaks screen reader focus trapping const showPrompt = ( id: string ) => { document. getElementById ( ' modal ' ). style. display = ' block '; document. getElementById ( ' modal-content ' ). focus (); // ❌ Lost context }; Enter fullscreen mode Exit fullscreen mode Root cause: No focus restoration on close, no aria-modal="true" attribute, and animations triggered layout recalculations on every frame due to animating width / height directly.
Switching to Radix Primitives solved the accessibility contract while keeping TailwindCSS for styling:
import * as Dialog from ' @radix-ui/react-dialog '; import { useEffect, useRef } from ' react '; interface PromptModalProps { prompt: { id: string; title: string; content: string; tokens?: number }; open: boolean; onClose: () => void; } export function PromptModal ({ prompt, open, onClose }: PromptModalProps ) { const textareaRef = useRef < HTMLTextAreaElement > ( null ); useEffect (() => { if ( open && textareaRef. current ) { textareaRef. current. select (); } }, [ open ]); return ( < Dialog. Root open = { open } onOpenChange = { onClose } > < Dialog. Portal > < Dialog.
Overlay className = "fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" /> < Dialog. Content className = "fixed left-[50%] top-[50%] z-50 w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] rounded-lg border border-gray-200 bg-white p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]" aria-describedby = "prompt-description" > < Dialog. Title className = "text-xl font-semibold text-gray-900" > { prompt. title } </ Dialog.
Title > < Dialog. Description id = "prompt-description" className = "sr-only" > Prompt content for copying into your AI workflow </ Dialog. Description > < TokenAwareTextarea ref = { textareaRef } value = { prompt. content } maxTokens = { prompt. tokens } className = "mt-4" /> < div className = "mt-4 flex justify-end gap-2" > < button onClick = { () => { navigator. clipboard. writeText ( prompt. content ); } } className = "rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" > Copy to Clipboard </ button > < Dialog.
Close asChild > < button className = "rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" > Close </ button > </ Dialog. Close > </ div > </ Dialog. Content > </ Dialog.
Portal > </ Dialog. Root > ); } Enter fullscreen mode Exit fullscreen mode Token-Aware Textarea with Visual Feedback When integrating with AI gateways (we route through https://b-lost.com/v1 for multi-provider fallback), token limits vary by model. The textarea needs to warn users before they hit the ceiling:
import { forwardRef, useMemo } from ' react '; import { encode } from ' gpt-tokenizer '; // or tiktoken interface TokenAwareTextareaProps { value: string; maxTokens?: number; className?: string; } export const TokenAwareTextarea = forwardRef < HTMLTextAreaElement, TokenAwareTextareaProps > (({ value, maxTokens = 4096, className = '' }, ref ) => { const tokenCount = useMemo (() => encode ( value ). length, [ value ]); const isNearLimit = tokenCount > maxTokens * 0.9; const isOverLimit = tokenCount > maxTokens; return ( < div className = { className } > < textarea ref = { ref } value = { value } readOnly rows = { 12 } className = { `w-full rounded border p-3 font-mono text-sm focus:outline-none focus:ring-2 ${ isOverLimit? ' border-red-500 bg-red-50 focus:ring-red-500 ': isNearLimit? ' border-yellow-500 bg-yellow-50 focus:ring-yellow-500 ': ' border-gray-300 focus:ring-blue-500 ' } ` } aria-describedby = "token-count" /> < div id = "token-count" className = { `mt-1 text-right text-xs ${ isOverLimit? ' text-red-600 ': isNearLimit? ' text-yellow-600 ': ' text-gray-500 ' } ` } > { tokenCount } / { maxTokens } tokens { isOverLimit && ' — exceeds model limit ' } </ div > </ div > ); }); TokenAwareTextarea. displayName = ' TokenAwareTextarea '; Enter fullscreen mode Exit fullscreen mode CSS Containment for Prompt List Performance Rendering 2,000+ prompts with Markdown preview caused 300ms layout recalculations. Adding contain: layout style to each card container dropped it to 45ms:
/* Tailwind arbitrary property */.prompt-card { @apply rounded-lg border border-gray-200 p-4; contain: layout style; } Enter fullscreen mode Exit fullscreen mode For dynamic height cards with auto-rows-auto grid, use content-visibility: auto instead:
< div className = "grid auto-rows-auto gap-4 md:grid-cols-2 lg:grid-cols-3" > { prompts. map (( p ) => ( < article key = { p. id } className = "prompt-card [content-visibility:auto]" style = { { contentVisibilityAutoStateCallback: true } as any } > { /* Card content */ } </ article > )) } </ div > Enter fullscreen mode Exit fullscreen mode The Remaining Question: State Synchronization f/prompts.chat's architecture assumes prompts are relatively static. But when integrating with live AI sessions, you need bidirectional sync—users might annotate prompts with execution results or pin favorites. Do you:
Each choice trades off data consistency, deployment complexity, and user experience. What's your approach when the upstream data model doesn't match your runtime needs?