Form Elements
Unify all form elements — inputs, selects, textareas, checkboxes, toggles, radio buttons,
Forms are the most visible inconsistency vector in vibecoded websites because they combine multiple interactive elements -- inputs, selects, buttons, labels, error messages -- that must share a common visual language. When a text input is 36px tall, the select next to it is 40px, and the submit button is 38px, the misalignment is immediately visible even to non-designers. Form unification starts with a single shared set of dimensions, borders, focus rings, and disabled states that every form element inherits. ## Key Points 1. **Input heights don't match.** A text input is 36px, the select next to it is 40px, and 2. **Focus styles are inconsistent.** Some inputs get a ring, some get a border color change, 3. **Border radius varies.** 4px on text inputs, 8px on selects, fully rounded on search bars. 4. **Labels positioned differently.** Above on one form, inline on another, floating on a third. 5. **Error states don't match.** Red border here, red text there, a tooltip somewhere else. 6. **Upload buttons are orphaned.** They look completely different from every other button 7. **Disabled states are inconsistent.** Some gray out, some reduce opacity, some do nothing visible. - 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
skilldb get web-polish-skills/Form ElementsFull skill: 409 linesForm Element Unification
Core Philosophy
Forms are the most visible inconsistency vector in vibecoded websites because they combine multiple interactive elements -- inputs, selects, buttons, labels, error messages -- that must share a common visual language. When a text input is 36px tall, the select next to it is 40px, and the submit button is 38px, the misalignment is immediately visible even to non-designers. Form unification starts with a single shared set of dimensions, borders, focus rings, and disabled states that every form element inherits.
The golden rule of form design is that every form element on the site must share the same border color, width, radius, focus ring, height per size variant, disabled appearance, error appearance, font size, and transition timing. If any of these properties differ between two form elements, the inconsistency is visible and the site feels unpolished. This rule is absolute and applies to text inputs, selects, textareas, checkboxes, toggles, radio buttons, and file uploads equally.
File upload components are the most commonly orphaned form element. They are almost always styled independently from the rest of the form system because they were built in a separate prompt or at a different time. Bringing the file upload into visual alignment with the site's button and input styling is one of the highest-impact form unification tasks.
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.
Install this skill directly: skilldb add web-polish-skills
Related Skills
Color System Repair
Fix color chaos in a vibecoded website — too many near-duplicate colors, inconsistent
Component Unification
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 Extraction
Extract a unified set of design tokens (colors, typography, spacing, shadows, radii) from a
Modal Dialog System
Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a
Navigation Patterns
Fix inconsistent navigation patterns — mismatched headers, footers, sidebars, breadcrumbs,