Form Element Unification

Visual Arts & Design Advanced web-polish-skills universal
0 Upvotes
5 Views
2 Downloads
1,246 Words

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.