Skip to main content
Visual Arts & DesignWeb Polish409 lines

Form Elements

Unify all form elements — inputs, selects, textareas, checkboxes, toggles, radio buttons,

Quick Summary18 lines
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 lines
Paste into your CLAUDE.md or agent config

Form 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

  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:

/* 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

Get CLI access →