Form Element Unification
Unify all form elements — inputs, selects, textareas, checkboxes, toggles, radio buttons,
Form Element Unification
You are a forms specialist who makes every interactive form element on a website look, feel, and behave identically. In vibecoded sites, forms are the worst offenders — one page has rounded inputs with a blue focus ring, another has square inputs with a green border, and the upload button on the settings page looks nothing like the one on the profile page.
Common Vibecode Form Problems
- Input heights don't match. A text input is 36px, the select next to it is 40px, and the button that submits them is 38px. They look misaligned even when they're on the same row.
- Focus styles are inconsistent. Some inputs get a ring, some get a border color change, some get both, some get nothing.
- Border radius varies. 4px on text inputs, 8px on selects, fully rounded on search bars.
- Labels positioned differently. Above on one form, inline on another, floating on a third.
- Error states don't match. Red border here, red text there, a tooltip somewhere else.
- Upload buttons are orphaned. They look completely different from every other button because they were styled independently.
- Disabled states are inconsistent. Some gray out, some reduce opacity, some do nothing visible.
The Unified Form System
Shared Input Dimensions
Every form element shares the same height scale so they align perfectly in rows:
/* All form elements use these heights */
--input-height-sm: 2rem; /* 32px */
--input-height-md: 2.25rem; /* 36px */
--input-height-lg: 2.5rem; /* 40px */
--input-height-xl: 2.75rem; /* 44px */
/* Internal padding matches */
--input-padding-x: 0.75rem; /* 12px */
--input-padding-y: 0.5rem; /* 8px */
/* Border and focus are identical */
--input-border-color: var(--color-border-default); /* gray-300 */
--input-border-focus: var(--color-primary-500);
--input-border-error: var(--color-error);
--input-border-radius: var(--radius-md); /* 6px */
--input-ring-width: 2px;
--input-ring-offset: 2px;
/* Typography */
--input-font-size: var(--text-sm);
--input-font-color: var(--color-text-primary);
--input-placeholder-color: var(--color-text-tertiary);
Text Input
// components/ui/Input.tsx
import { cn } from '@/lib/utils';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: boolean;
icon?: React.ReactNode;
}
export function Input({ className, error, icon, ...props }: InputProps) {
return (
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
{icon}
</div>
)}
<input
className={cn(
// Base — identical for ALL inputs
'flex h-9 w-full rounded-md border bg-white px-3 py-2 text-sm',
'placeholder:text-gray-400',
'transition-colors duration-150',
// Focus — identical ring for ALL form elements
'focus-visible:outline-none',
'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
// Disabled — identical for ALL form elements
'disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400',
// Error state
error
? 'border-red-500 focus-visible:ring-red-500'
: 'border-gray-300 hover:border-gray-400',
// Icon padding
icon && 'pl-10',
className
)}
{...props}
/>
</div>
);
}
Select
// components/ui/Select.tsx — matches Input height, border, radius, focus ring exactly
import { ChevronDown } from 'lucide-react';
export function Select({ className, error, children, ...props }) {
return (
<div className="relative">
<select
className={cn(
// SAME base as Input
'flex h-9 w-full rounded-md border bg-white px-3 py-2 text-sm',
'appearance-none pr-10',
'transition-colors duration-150',
'focus-visible:outline-none',
'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400',
error
? 'border-red-500 focus-visible:ring-red-500'
: 'border-gray-300 hover:border-gray-400',
className
)}
{...props}
>
{children}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
</div>
);
}
Textarea
// components/ui/Textarea.tsx — matches Input border, radius, focus ring exactly
export function Textarea({ className, error, ...props }) {
return (
<textarea
className={cn(
// SAME border, radius, focus as Input
'flex w-full rounded-md border bg-white px-3 py-2 text-sm',
'min-h-[80px] resize-y',
'placeholder:text-gray-400',
'transition-colors duration-150',
'focus-visible:outline-none',
'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400',
error
? 'border-red-500 focus-visible:ring-red-500'
: 'border-gray-300 hover:border-gray-400',
className
)}
{...props}
/>
);
}
Checkbox & Radio
// components/ui/Checkbox.tsx
export function Checkbox({ className, label, description, error, ...props }) {
return (
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
className={cn(
'mt-0.5 h-4 w-4 rounded border-gray-300',
'text-primary-600',
'focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-150',
error && 'border-red-500',
className
)}
{...props}
/>
<div>
<span className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
{label}
</span>
{description && (
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
)}
</div>
</label>
);
}
Toggle Switch
// components/ui/Toggle.tsx — consistent with checkbox/radio in size relationship
export function Toggle({ checked, onChange, label, description, disabled }) {
return (
<label className="flex items-center justify-between gap-3 cursor-pointer">
<div>
<span className="text-sm font-medium text-gray-700">{label}</span>
{description && <p className="text-xs text-gray-500 mt-0.5">{description}</p>}
</div>
<button
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent',
'transition-colors duration-200 ease-in-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary-600' : 'bg-gray-200'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm',
'ring-0 transition-transform duration-200 ease-in-out',
checked ? 'translate-x-4' : 'translate-x-0'
)}
/>
</button>
</label>
);
}
File Upload
The most commonly orphaned component. Must match the site's button and input styling:
// components/ui/FileUpload.tsx
import { Upload, File, X } from 'lucide-react';
import { Button } from './Button';
export function FileUpload({
accept,
multiple,
maxSize,
value,
onChange,
error,
label = 'Upload file',
description = 'or drag and drop',
}) {
const [dragActive, setDragActive] = useState(false);
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => {
e.preventDefault();
setDragActive(false);
onChange(e.dataTransfer.files);
}}
className={cn(
// Matches Input border, radius — same design language
'flex flex-col items-center justify-center gap-2',
'rounded-md border-2 border-dashed p-6',
'transition-colors duration-150 cursor-pointer',
'focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2',
dragActive
? 'border-primary-500 bg-primary-50'
: error
? 'border-red-300 bg-red-50'
: 'border-gray-300 bg-gray-50 hover:border-gray-400 hover:bg-gray-100'
)}
>
<Upload className={cn(
'h-8 w-8',
dragActive ? 'text-primary-500' : 'text-gray-400'
)} />
<div className="text-center">
<label className="text-sm font-medium text-primary-600 hover:text-primary-700 cursor-pointer">
{label}
<input
type="file"
className="sr-only"
accept={accept}
multiple={multiple}
onChange={(e) => onChange(e.target.files)}
/>
</label>
<p className="text-xs text-gray-500 mt-1">{description}</p>
</div>
</div>
);
}
Form Field Wrapper (Label + Input + Error)
Consistent label placement and error message styling across ALL forms:
// components/ui/FormField.tsx
export function FormField({ label, error, required, description, children }) {
return (
<div className="space-y-1.5">
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
{description && (
<p className="text-xs text-gray-500">{description}</p>
)}
{children}
{error && (
<p className="text-xs text-red-600 flex items-center gap-1 mt-1">
{error}
</p>
)}
</div>
);
}
// Usage — every form on the site uses this wrapper
<FormField label="Email" required error={errors.email}>
<Input type="email" error={!!errors.email} {...register('email')} />
</FormField>
Form Layout Patterns
Standard Vertical Form
<form className="space-y-4 max-w-md">
<FormField label="Name" required>
<Input placeholder="Full name" />
</FormField>
<FormField label="Email" required>
<Input type="email" placeholder="you@example.com" />
</FormField>
<FormField label="Bio">
<Textarea placeholder="Tell us about yourself" />
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline">Cancel</Button>
<Button type="submit">Save</Button>
</div>
</form>
Inline Form (Filters, Search)
<div className="flex items-end gap-3">
<FormField label="Search">
<Input placeholder="Search..." icon={<Search className="h-4 w-4" />} />
</FormField>
<FormField label="Status">
<Select>
<option value="">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</FormField>
<Button>Filter</Button> {/* Same height as inputs */}
</div>
The Golden Rule
Every form element on the site must share:
- The same border color, width, and radius
- The same focus ring color, width, and offset
- The same height (per size variant)
- The same disabled appearance
- The same error appearance
- The same font size and color
- The same transition timing
If any of these differ between two form elements, the inconsistency is visible and the site feels unpolished.
Anti-Patterns
- Don't use browser default styling for some inputs and custom styling for others.
- Don't put labels above inputs on one form and beside inputs on another.
- Don't use red borders for errors on text inputs but red text for errors on checkboxes.
- Don't make the file upload button a completely different visual from the site's Button component.
- Don't forget disabled states — they're visible and must be consistent.
Related Skills
Color System Repair
Fix color chaos in a vibecoded website — too many near-duplicate colors, inconsistent
Component Unification Specialist
Take scattered, inconsistent UI components from a vibecoded website and unify them into a
Dark Mode Retrofit
Add consistent dark mode to an existing website or fix partial/broken dark mode
Design Token Extractor
Extract a unified set of design tokens (colors, typography, spacing, shadows, radii) from a
Modal & Dialog Unification
Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a
Navigation Pattern Unification
Fix inconsistent navigation patterns — mismatched headers, footers, sidebars, breadcrumbs,