Form Element Unification
Description
Unify and standardize all form controls (inputs, selects, textareas, checkboxes, radios, toggles) for consistent height, borders, focus, and states across a site.
When to Use
How do I unify all form elements on my site? | I need a consistent form UI across pages. | Standardize inputs, selects, and buttons. | Create a unified form system for better UX. | Align heights, borders, and focus styles across forms.
Use Cases
Standardize all input heights across forms. | Apply identical focus and border styles. | Unify upload buttons with other controls. | Fix misaligned labels and states site-wide.
SKILL.md Content
---
name: form-elements
description: "Unify and standardize all form controls (inputs, selects, textareas, checkboxes, radios, toggles) for consistent height, borders, focus, and states across a site."
metadata:
tags: "visual-design, web-forms, form-unification, consistent-ui, responsive-design, css-variables, design-systems, ui-polish"
source: "https://skilldb.dev/skills/web-polish-skills/form-elements"
pack: "web-polish-skills"
category: "Visual Arts & Design"
---
# Form Element Unification
## When to use this skill
Use when the user says things like:
- "How do I unify all form elements on my site?"
- "I need a consistent form UI across pages."
- "Standardize inputs, selects, and buttons."
- "Create a unified form system for better UX."
- "Align heights, borders, and focus styles across forms."
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
1. **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.
2. **Focus styles are inconsistent.** Some inputs get a ring, some get a border color change,
some get both, some get nothing.
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
because they were styled independently.
7. **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:
```css
/* 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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:
```tsx
// 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:
```tsx
// 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
```tsx
<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)
```tsx
<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.