src/app/UI_GUIDELINES.md
Rules for writing React components in the Promptfoo frontend.
Never use raw text or inline styles. Use semantic HTML elements with consistent Tailwind typography classes.
// ✅ Good - semantic HTML with Tailwind
<h1 className="text-2xl font-bold tracking-tight">Page Title</h1>
<h2 className="text-lg font-semibold">Section Title</h2>
<p className="text-sm text-muted-foreground">Description text</p>
<span className="text-xs font-medium uppercase tracking-wide">Label</span>
// ❌ Bad - unstyled or inconsistent
<div>Page Title</div>
<span style={{ fontSize: '14px' }}>Description</span>
<p>Some text</p>
Typography scale:
| Use Case | Classes |
|---|---|
| Page title | text-2xl font-bold tracking-tight |
| Section title | text-lg font-semibold |
| Card title | text-base font-medium |
| Body text | text-sm |
| Muted/secondary | text-sm text-muted-foreground |
| Labels/caps | text-xs font-medium uppercase tracking-wide |
Build complex UI by composing small primitives from components/ui/. Each component should do one thing well.
// ✅ Good - compose primitives
function ConfirmDeleteDialog({ onConfirm, itemName }) {
return (
<Dialog>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {itemName}?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ❌ Bad - monolithic component with many props
function ConfirmDeleteDialog({
onConfirm,
onCancel,
itemName,
showIcon,
iconColor,
cancelText,
confirmText,
confirmVariant,
size,
centered,
// ...
}) {
// Too many props, hard to maintain
}
Rule: If a component has more than 5-6 props, consider breaking it into composed primitives.
Use icons sparingly and only when they add meaning. Prefer text labels over icon-only buttons.
// ✅ Good - icons add meaning to actions
<Button variant="outline" size="sm">
<Download className="size-4 mr-2" />
Export
</Button>
// ✅ Good - status indicator with icon
<div className="flex items-center gap-2">
<CheckCircle className="size-4 text-emerald-600" />
<span className="text-sm">Passed</span>
</div>
// ❌ Bad - decorative icons that add no meaning
<Card>
<Sparkles className="size-5" />
<h3>Settings</h3>
<Star className="size-4" />
</Card>
// ❌ Bad - icon-only buttons without labels (accessibility issue)
<Button size="icon"><Settings /></Button> // What does this do?
// ✅ Better - icon with aria-label
<Button size="icon" aria-label="Settings">
<Settings className="size-4" />
</Button>
Icon sizing: Use size-4 for inline/buttons, size-5 for emphasis, size-8 or larger only for empty states.
Presentational components receive data as props. Data fetching happens in container components, hooks, or route loaders.
// ✅ Good - presentational component (pure, testable)
interface EvalListProps {
evals: Eval[];
onSelect: (id: string) => void;
isLoading: boolean;
}
function EvalList({ evals, onSelect, isLoading }: EvalListProps) {
if (isLoading) return <Skeleton />;
return (
<ul>
{evals.map(eval => (
<EvalListItem key={eval.id} eval={eval} onSelect={onSelect} />
))}
</ul>
);
}
// ✅ Good - container handles data fetching
function EvalListContainer() {
const { data: evals, isLoading } = useEvals();
const navigate = useNavigate();
return (
<EvalList
evals={evals ?? []}
isLoading={isLoading}
onSelect={(id) => navigate(`/eval/${id}`)}
/>
);
}
// ❌ Bad - fetching inside presentational component
function EvalList() {
const [evals, setEvals] = useState([]);
useEffect(() => {
callApi('/evals').then(setEvals); // Mixing concerns
}, []);
return <ul>{evals.map(...)}</ul>;
}
Leverage React 19 features for cleaner, more performant code.
use() for reading promises and context// ✅ Good - use() for async data in components
function EvalDetails({ evalPromise }: { evalPromise: Promise<Eval> }) {
const eval = use(evalPromise); // Suspends until resolved
return <div>{eval.name}</div>;
}
// ✅ Good - use() for context (cleaner than useContext)
function ThemedButton() {
const theme = use(ThemeContext);
return <Button className={theme.buttonClass}>Click</Button>;
}
useActionState for form submissions// ✅ Good - useActionState for forms
function CreateEvalForm() {
const [state, submitAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
const result = await createEval(formData);
return result.error ? { error: result.error } : { success: true };
},
{ error: null },
);
return (
<form action={submitAction}>
<Input name="name" />
{state.error && <p className="text-destructive text-sm">{state.error}</p>}
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</Button>
</form>
);
}
// ❌ Bad - manual state management for forms
function CreateEvalForm() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
await createEval(new FormData(e.target));
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
// ...
}
useOptimistic for instant feedback// ✅ Good - optimistic updates
function TodoList({ todos, onToggle }) {
const [optimisticTodos, setOptimisticTodo] = useOptimistic(todos, (state, updatedTodo) =>
state.map((t) => (t.id === updatedTodo.id ? updatedTodo : t)),
);
const handleToggle = async (todo) => {
setOptimisticTodo({ ...todo, completed: !todo.completed });
await onToggle(todo.id); // Server update
};
return optimisticTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
));
}
useTransition for non-blocking updates// ✅ Good - transitions for expensive updates
function SearchableList({ items }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleSearch = (e) => {
const value = e.target.value;
setQuery(value); // Urgent: update input immediately
startTransition(() => {
setFilteredItems(filterItems(items, value)); // Non-urgent: can be interrupted
});
};
return (
<>
<Input value={query} onChange={handleSearch} />
{isPending && <Spinner className="size-4" />}
<List items={filteredItems} />
</>
);
}
// ✅ Good - React 19: ref is just a prop
function CustomInput({ ref, ...props }) {
return <input ref={ref} {...props} className="..." />;
}
// Usage
<CustomInput ref={inputRef} />;
// ❌ Outdated - forwardRef wrapper
const CustomInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
Use border-border (CSS variable) for standard borders. Never use pure black or overly thick borders.
// ✅ Good - subtle borders
<Card className="border border-border" />
<div className="border-b border-border" />
<div className="rounded-lg shadow-sm" /> // Shadow instead of border
<div className="ring-1 ring-black/5 dark:ring-white/10" /> // Very subtle
// ✅ Good - semantic borders with opacity in dark mode
<div className="border border-red-200 dark:border-red-800/50" />
// ❌ Bad - harsh borders
<div className="border border-black" />
<div className="border-2 border-gray-900" />
In dark mode, use opacity modifiers for backgrounds to maintain subtlety.
// ✅ Good - opacity for dark mode backgrounds
className = 'bg-red-50 dark:bg-red-950/30';
className = 'bg-muted/50 dark:bg-muted/20';
// ❌ Bad - solid dark backgrounds (too harsh)
className = 'bg-red-50 dark:bg-red-900';
Use a consistent pattern for severity/status colors across bg, border, text, and icons.
const severityClasses = {
critical: {
container: 'bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800/50',
text: 'text-red-700 dark:text-red-300',
icon: 'text-red-600 dark:text-red-400',
},
warning: {
container: 'bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800/50',
text: 'text-amber-700 dark:text-amber-300',
icon: 'text-amber-600 dark:text-amber-400',
},
success: {
container: 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-800/50',
text: 'text-emerald-700 dark:text-emerald-300',
icon: 'text-emerald-600 dark:text-emerald-400',
},
info: {
container: 'bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800/50',
text: 'text-blue-700 dark:text-blue-300',
icon: 'text-blue-600 dark:text-blue-400',
},
};
Use established layout components for consistent page structure.
// ✅ Good - consistent page structure
import { PageContainer, PageHeader } from '@app/components/layout';
function MyPage() {
return (
<PageContainer>
<PageHeader>
<div className="container max-w-7xl mx-auto px-4 py-10">
<h1 className="text-2xl font-bold tracking-tight">Page Title</h1>
<p className="text-muted-foreground mt-1">Description</p>
</div>
</PageHeader>
<div className="container max-w-7xl mx-auto px-4 py-8">
<Card className="bg-white dark:bg-zinc-900"></Card>
</div>
</PageContainer>
);
}
// ❌ Bad - inconsistent one-off layout
function MyPage() {
return (
<div style={{ padding: '20px' }}>
<h1>Page Title</h1>
<div></div>
</div>
);
}
When rendering content in DataTable cells, ensure content can shrink gracefully when columns are resized.
// ✅ Good - Badge with truncate prop for table cells
cell: ({ row }) => {
const status = row.getValue('status') as string;
return (
<Badge variant="success" truncate>
{status}
</Badge>
);
};
// ❌ Bad - Badge without truncate (overflows when column shrinks)
cell: ({ row }) => {
const status = row.getValue('status') as string;
return <Badge variant="success">{status}</Badge>;
};
Rules for table cell content:
truncate prop on Badge components in table cellstruncate class or text-ellipsis overflow-hiddenmin-w-0 to allow shrinkingcomponents/ui/ firstBorders (IMPORTANT):
border-border for standard borders (soft blue-gray)dark:border-gray-800/50)shadow-sm or ring-1 ring-black/5 instead of borders for elevationborder-2 for focus/selected states only// Good
className = 'border border-border'; // Soft, uses CSS variable
className = 'rounded-lg shadow-sm'; // Borderless with shadow
className = 'border border-red-200 dark:border-red-900/50'; // Severity with opacity
// Bad
className = 'border border-black'; // Never pure black
className = 'border-2 border-gray-900'; // Too harsh
Colors:
dark:bg-red-950/30 not dark:bg-red-900*-700 in light mode, *-300 in dark mode// Good - compose from primitives
function ConfirmDialog({ title, onConfirm }) {
return (
<Dialog>
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button variant="destructive" onClick={onConfirm}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Bad - monolithic component with many props
function ConfirmDialog({ title, cancelText, confirmText, variant, size, ... }) {
// Too many props, hard to maintain
}
useMemo: Use when computing a value (non-callable result)useCallback: Use when creating a stable function reference// Good - useMemo for computed values
const tooltipMessage = useMemo(() => {
return apiStatus === 'blocked' ? 'Connection failed' : undefined;
}, [apiStatus]);
// Good - useCallback for functions with arguments
const handleClick = useCallback((id: string) => {
console.log('Clicked:', id);
}, []);
// Bad - useCallback for computed values
const getTooltipMessage = useCallback(() => {
return apiStatus === 'blocked' ? 'Connection failed' : undefined;
}, [apiStatus]);
General:
fetch() instead of callApi()@app/constants/routes)useCallback for computed values (use useMemo instead)@app/* maps to src/* (configured in vite.config.ts)@/components/ui/* - Radix primitives