Form Patterns
A collection of production-ready form patterns for building accessible, user-friendly forms. Each pattern demonstrates best practices for layout, validation, state management, and user experience.
Basic Form
A simple contact form with name, email, and message fields. Demonstrates controlled inputs with form submission.
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium mb-1.5">Name</label>
<input
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-(--surface-sunken) border border-(--border)
rounded-lg px-3 py-2 text-sm"
required
/>
</div>
<div>
<label>Email</label>
<input type="email" value={email} onChange={...} />
</div>
<div>
<label>Message</label>
<textarea value={message} onChange={...} rows={4} />
</div>
<button type="submit">Send Message</button>
</form>Inline Validation
Form fields with real-time validation feedback. Fields show green for valid input and red for errors as the user types.
const [email, setEmail] = useState('');
const emailValid = email.length === 0
? null
: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
<div>
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={emailValid === null
? inputClass
: emailValid ? inputSuccessClass : inputErrorClass}
/>
{emailValid === false && (
<p className="text-xs text-danger-500 mt-1">
Please enter a valid email
</p>
)}
{emailValid === true && (
<p className="text-xs text-green-500 mt-1">Valid email</p>
)}
</div>Multi-Step Form
A stepper form that breaks complex flows into manageable steps. Includes a progress indicator, navigation controls, and step validation.
const [currentStep, setCurrentStep] = useState(0);
const steps = ['Personal', 'Company', 'Plan'];
{/* Steps indicator */}
<div className="flex items-center justify-between">
{steps.map((step, i) => (
<div key={step} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center
justify-center ${i <= currentStep
? 'bg-brand-500 text-white'
: 'bg-(--surface-sunken) border border-(--border)'
}`}>
{i < currentStep ? <CheckIcon /> : i + 1}
</div>
{i < steps.length - 1 && (
<div className={`w-24 h-0.5 ${i < currentStep
? 'bg-brand-500' : 'bg-(--border)'}`} />
)}
</div>
))}
</div>
{/* Step content with AnimatePresence */}
{currentStep === 0 && <StepPersonal />}
{currentStep === 1 && <StepCompany />}
{currentStep === 2 && <StepPlan />}
{/* Navigation */}
<button onClick={() => setCurrentStep(s => s - 1)}>Previous</button>
<button onClick={() => setCurrentStep(s => s + 1)}>Next</button>Search & Filter
A search input combined with filter chips for content discovery. Filter chips allow toggling multiple categories.
const [searchQuery, setSearchQuery] = useState('');
const [activeFilters, setActiveFilters] = useState(['All']);
const filters = ['All', 'Components', 'Patterns', 'Foundation'];
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2" />
<input
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 ..."
/>
</div>
<div className="flex flex-wrap gap-2">
{filters.map((filter) => (
<button
key={filter}
onClick={() => toggleFilter(filter)}
className={activeFilters.includes(filter)
? 'bg-brand-500/10 text-brand-500'
: 'text-(--text-muted) border-(--border)'}
>
{filter}
</button>
))}
</div>Settings Form
A settings page with toggle switches, select dropdowns, and grouped sections for organizing preferences.
Appearance
Dark Mode
Toggle dark theme
Notifications
Email Notifications
Receive updates via email
const [darkMode, setDarkMode] = useState(false);
const [language, setLanguage] = useState('en');
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold">Appearance</h3>
<div className="p-4 rounded-lg border bg-(--surface-sunken)">
{/* Toggle Switch */}
<div className="flex items-center justify-between">
<span>Dark Mode</span>
<button
onClick={() => setDarkMode(!darkMode)}
className={`relative w-10 h-5.5 rounded-full
${darkMode ? 'bg-brand-500' : 'bg-(--border)'}`}
>
<span className={`w-4 h-4 rounded-full bg-white
${darkMode ? 'translate-x-5' : 'translate-x-0.75'}`} />
</button>
</div>
{/* Select Dropdown */}
<select value={language} onChange={...}>
<option value="en">English</option>
<option value="id">Bahasa Indonesia</option>
</select>
</div>
</div>
</div>Login Form
A classic login form with email and password fields, remember-me checkbox, and a forgot password link.
Welcome back
Sign in to your account to continue
Don't have an account? Sign up
<div className="p-6 rounded-xl border bg-(--surface-sunken)">
<h3>Welcome back</h3>
<p>Sign in to your account to continue</p>
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input type="email" placeholder="you@example.com" />
</div>
<div>
<div className="flex justify-between">
<label>Password</label>
<a href="#">Forgot password?</a>
</div>
<input type="password" />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="remember" />
<label htmlFor="remember">Remember me</label>
</div>
<button type="submit">Sign In</button>
</form>
<p>Don't have an account? <a href="#">Sign up</a></p>
</div>Form Layout
Comparison of horizontal (inline) and vertical (stacked) label placement. Horizontal works best on wider screens, vertical is more mobile-friendly.
Vertical (Stacked) Labels
Horizontal (Inline) Labels
{/* Vertical (Stacked) — default, mobile-friendly */}
<div className="space-y-4">
<div>
<label className="block text-xs font-medium mb-1.5">
First Name
</label>
<input className="w-full ..." />
</div>
</div>
{/* Horizontal (Inline) — wider screens */}
<div className="space-y-4">
<div className="flex items-center gap-4">
<label className="w-24 text-right flex-shrink-0">
First Name
</label>
<input className="w-full ..." />
</div>
</div>Disabled States
Forms in disabled or read-only mode. Use disabled for fields that cannot be changed, and read-only for fields that are informational.
This field is read-only and cannot be edited
disabled when the field cannot be interacted with at all (grayed out, not focusable). Use readOnly when the value should be visible and selectable but not editable.{/* Disabled — grayed out, not focusable */}
<input
type="text"
value="Jane Smith"
disabled
className="... opacity-60 cursor-not-allowed"
/>
{/* Read-only — selectable but not editable */}
<input
type="text"
value="Administrator"
readOnly
className="... cursor-default"
/>Best Practices
Guidelines for building effective, accessible, and user-friendly forms.
- Always provide visible labels for every form field
- Show validation errors inline near the relevant field
- Use appropriate input types (email, tel, url, number)
- Group related fields together with clear section headings
- Break long forms into multi-step flows with progress indicators
- Provide clear, actionable error messages
- Use placeholder text as a replacement for labels
- Validate every keystroke without debouncing
- Show all errors at the top of the form only
- Use generic error messages like "Invalid input"
- Ask for unnecessary information or too many fields
- Rely solely on color to indicate validation state
Related
Components commonly used together when building forms.
Input
Flexible text input with variants, validation states, and icon support.
Select
Dropdown selection with search, multi-select, and custom options.
Checkbox
Boolean toggle with label and description support.
Switch
Toggle switch for on/off preferences and settings.
Button
Action buttons with variants, sizes, and loading states.