Skip to main content
Visual Arts & DesignWeb Polish408 lines

Form Elements

Unify all form elements — inputs, selects, textareas, checkboxes, toggles, radio buttons, file uploads, date pickers, and form layouts — into a consistent, accessible system. Trigger when forms look different across pages, upload buttons don't match, or form validation styling is inconsistent.

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: 408 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 →

Related Skills

Modal Dialog System

Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a single consistent system. Trigger when modals look different across pages, close buttons don't match, overlays have different opacity, or dialog behavior is inconsistent.

Web Polish317L

Navigation Patterns

Fix inconsistent navigation patterns — mismatched headers, footers, sidebars, breadcrumbs, tab bars, and mobile menus. Establish unified navigation components that work across all pages. Trigger when nav "looks different on different pages," active states don't match, or mobile menu is broken/missing.

Web Polish318L

Responsive Repair

Fix broken responsive layouts in a vibecoded website — content overflowing on mobile, misaligned grids, missing breakpoint handling, touch targets that are too small, and navigation that doesn't adapt. Trigger when things "break on mobile," "look bad on tablet," or "don't resize properly."

Web Polish301L

Typography Cleanup

Fix font chaos in a vibecoded website — too many font sizes, inconsistent weights, broken heading hierarchy, mismatched line heights, and mixed font families. Establish a clean type scale and apply it everywhere. Trigger when fonts "look off," headings are inconsistent, or there are too many text sizes.

Web Polish255L

UI Audit

Audit a vibecoded or inconsistent website for visual and structural problems — mismatched components, font chaos, color drift, spacing irregularities, broken states, and missing interactions. Produces a prioritized fix list. Trigger when users say "this looks off," "make it consistent," "audit the UI," "fix the design," or "polish this site."

Web Polish204L

Vibecode Upgrade

The comprehensive playbook for upgrading a vibecoded website from "it works" to "it feels premium." Combines all other web-polish skills into a prioritized workflow — audit, extract tokens, unify components, fix responsiveness, add polish. Trigger when users want to "upgrade the whole site," "make it look professional," "polish everything," or "it was vibecoded and needs to be cleaned up."

Web Polish237L