Claude-skill-registry form-design

Build accessible, user-friendly forms with validation. Covers react-hook-form, Zod schemas, error handling UX, multi-step forms, input patterns, and form accessibility. Use for registration forms, checkout flows, data entry, and user input.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/form-design" ~/.claude/skills/majiayu000-claude-skill-registry-form-design && rm -rf "$T"
manifest: skills/data/form-design/SKILL.md
source content

Form Design & Validation

Create accessible, validated forms with excellent user experience.

Instructions

  1. Use proper labels - Every input needs an associated label
  2. Validate on blur and submit - Immediate feedback without being intrusive
  3. Show clear error messages - Specific, actionable guidance
  4. Group related fields - Use fieldsets for logical groupings
  5. Support keyboard navigation - Tab order, Enter to submit

React Hook Form + Zod (Recommended Stack)

Setup

npm install react-hook-form zod @hookform/resolvers

Basic Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 1. Define schema
const signupSchema = z.object({
  email: z
    .string()
    .min(1, 'Email is required')
    .email('Please enter a valid email'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain an uppercase letter')
    .regex(/[0-9]/, 'Password must contain a number'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

type SignupForm = z.infer<typeof signupSchema>;

// 2. Create form
function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
  });

  const onSubmit = async (data: SignupForm) => {
    try {
      await api.signup(data);
      // Handle success
    } catch (error) {
      // Handle error
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <FormField
        label="Email"
        type="email"
        error={errors.email?.message}
        {...register('email')}
      />

      <FormField
        label="Password"
        type="password"
        error={errors.password?.message}
        {...register('password')}
      />

      <FormField
        label="Confirm Password"
        type="password"
        error={errors.confirmPassword?.message}
        {...register('confirmPassword')}
      />

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating account...' : 'Sign Up'}
      </button>
    </form>
  );
}

Reusable Form Field Component

import { forwardRef } from 'react';

interface FormFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  hint?: string;
}

export const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
  ({ label, error, hint, id, type = 'text', ...props }, ref) => {
    const inputId = id || label.toLowerCase().replace(/\s+/g, '-');
    const errorId = `${inputId}-error`;
    const hintId = `${inputId}-hint`;

    return (
      <div className="space-y-1.5">
        <label
          htmlFor={inputId}
          className="block text-sm font-medium text-gray-700 dark:text-gray-300"
        >
          {label}
          {props.required && <span className="text-red-500 ml-1">*</span>}
        </label>

        <input
          ref={ref}
          id={inputId}
          type={type}
          className={`
            w-full px-3 py-2 rounded-lg border transition-colors
            ${error
              ? 'border-red-500 focus:ring-red-500 focus:border-red-500'
              : 'border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500'
            }
            bg-white dark:bg-gray-800
            text-gray-900 dark:text-gray-100
            placeholder-gray-400
            focus:outline-none focus:ring-2
            disabled:bg-gray-100 disabled:cursor-not-allowed
          `}
          aria-invalid={!!error}
          aria-describedby={
            error ? errorId : hint ? hintId : undefined
          }
          {...props}
        />

        {hint && !error && (
          <p id={hintId} className="text-sm text-gray-500">
            {hint}
          </p>
        )}

        {error && (
          <p id={errorId} className="text-sm text-red-600 flex items-center gap-1" role="alert">
            <ExclamationCircleIcon className="w-4 h-4" />
            {error}
          </p>
        )}
      </div>
    );
  }
);

Common Form Patterns

Select/Dropdown

interface SelectFieldProps {
  label: string;
  options: { value: string; label: string }[];
  error?: string;
  placeholder?: string;
}

export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(
  ({ label, options, error, placeholder, ...props }, ref) => {
    const id = label.toLowerCase().replace(/\s+/g, '-');

    return (
      <div className="space-y-1.5">
        <label htmlFor={id} className="block text-sm font-medium text-gray-700">
          {label}
        </label>
        <select
          ref={ref}
          id={id}
          className={`
            w-full px-3 py-2 rounded-lg border
            ${error ? 'border-red-500' : 'border-gray-300'}
            bg-white focus:ring-2 focus:ring-blue-500
          `}
          aria-invalid={!!error}
          {...props}
        >
          {placeholder && (
            <option value="" disabled>
              {placeholder}
            </option>
          )}
          {options.map(opt => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
        {error && <p className="text-sm text-red-600">{error}</p>}
      </div>
    );
  }
);

Checkbox Group

interface CheckboxGroupProps {
  label: string;
  options: { value: string; label: string }[];
  value: string[];
  onChange: (value: string[]) => void;
  error?: string;
}

function CheckboxGroup({ label, options, value, onChange, error }: CheckboxGroupProps) {
  const handleChange = (optionValue: string, checked: boolean) => {
    if (checked) {
      onChange([...value, optionValue]);
    } else {
      onChange(value.filter(v => v !== optionValue));
    }
  };

  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700 mb-2">{label}</legend>
      <div className="space-y-2">
        {options.map(opt => (
          <label key={opt.value} className="flex items-center gap-2 cursor-pointer">
            <input
              type="checkbox"
              checked={value.includes(opt.value)}
              onChange={(e) => handleChange(opt.value, e.target.checked)}
              className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
            />
            <span className="text-gray-700">{opt.label}</span>
          </label>
        ))}
      </div>
      {error && <p className="text-sm text-red-600 mt-1">{error}</p>}
    </fieldset>
  );
}

Radio Group

interface RadioGroupProps {
  label: string;
  options: { value: string; label: string; description?: string }[];
  value: string;
  onChange: (value: string) => void;
  error?: string;
}

function RadioGroup({ label, options, value, onChange, error }: RadioGroupProps) {
  return (
    <fieldset>
      <legend className="text-sm font-medium text-gray-700 mb-3">{label}</legend>
      <div className="space-y-3">
        {options.map(opt => (
          <label
            key={opt.value}
            className={`
              flex items-start gap-3 p-4 rounded-lg border cursor-pointer
              ${value === opt.value
                ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
                : 'border-gray-200 hover:border-gray-300'
              }
            `}
          >
            <input
              type="radio"
              name={label}
              value={opt.value}
              checked={value === opt.value}
              onChange={() => onChange(opt.value)}
              className="mt-0.5 w-4 h-4 text-blue-600 focus:ring-blue-500"
            />
            <div>
              <span className="font-medium text-gray-900">{opt.label}</span>
              {opt.description && (
                <p className="text-sm text-gray-500">{opt.description}</p>
              )}
            </div>
          </label>
        ))}
      </div>
      {error && <p className="text-sm text-red-600 mt-2">{error}</p>}
    </fieldset>
  );
}

Multi-Step Form

import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';

const steps = [
  { id: 'account', title: 'Account', component: AccountStep },
  { id: 'profile', title: 'Profile', component: ProfileStep },
  { id: 'preferences', title: 'Preferences', component: PreferencesStep },
];

function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState(0);
  const methods = useForm({ mode: 'onChange' });

  const CurrentStepComponent = steps[currentStep].component;
  const isLastStep = currentStep === steps.length - 1;

  const next = async () => {
    const isValid = await methods.trigger(); // Validate current step
    if (isValid && !isLastStep) {
      setCurrentStep(prev => prev + 1);
    }
  };

  const back = () => {
    if (currentStep > 0) {
      setCurrentStep(prev => prev - 1);
    }
  };

  const onSubmit = async (data: FormData) => {
    await api.submit(data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        {/* Progress indicator */}
        <nav aria-label="Progress" className="mb-8">
          <ol className="flex items-center">
            {steps.map((step, index) => (
              <li key={step.id} className="flex items-center">
                <span className={`
                  w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
                  ${index < currentStep
                    ? 'bg-blue-600 text-white'
                    : index === currentStep
                    ? 'border-2 border-blue-600 text-blue-600'
                    : 'border-2 border-gray-300 text-gray-500'
                  }
                `}>
                  {index < currentStep ? '✓' : index + 1}
                </span>
                <span className="ml-2 text-sm font-medium text-gray-900">
                  {step.title}
                </span>
                {index < steps.length - 1 && (
                  <div className="w-12 h-0.5 mx-4 bg-gray-200" />
                )}
              </li>
            ))}
          </ol>
        </nav>

        {/* Current step */}
        <CurrentStepComponent />

        {/* Navigation */}
        <div className="flex justify-between mt-8">
          <button
            type="button"
            onClick={back}
            disabled={currentStep === 0}
            className="px-4 py-2 border rounded-lg disabled:opacity-50"
          >
            Back
          </button>

          {isLastStep ? (
            <button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg">
              Submit
            </button>
          ) : (
            <button type="button" onClick={next} className="px-4 py-2 bg-blue-600 text-white rounded-lg">
              Continue
            </button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

Error Handling Patterns

Inline Errors

// Show error immediately below field
{error && (
  <p className="text-sm text-red-600 mt-1 flex items-center gap-1">
    <ExclamationCircleIcon className="w-4 h-4 flex-shrink-0" />
    {error}
  </p>
)}

Error Summary

// Show all errors at top of form
function ErrorSummary({ errors }: { errors: Record<string, { message?: string }> }) {
  const errorList = Object.entries(errors).filter(([_, v]) => v.message);

  if (errorList.length === 0) return null;

  return (
    <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6" role="alert">
      <h3 className="text-red-800 font-medium mb-2">
        Please fix the following errors:
      </h3>
      <ul className="list-disc list-inside text-sm text-red-700 space-y-1">
        {errorList.map(([field, error]) => (
          <li key={field}>
            <a href={`#${field}`} className="underline">
              {error.message}
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Accessibility Checklist

  • Every input has a
    <label>
    with
    htmlFor
  • Required fields are marked (and announced)
  • Error messages are linked with
    aria-describedby
  • Invalid fields have
    aria-invalid="true"
  • Error messages use
    role="alert"
    for screen readers
  • Focus moves to first error on submit
  • Tab order is logical
  • Submit with Enter key works

Best Practices

  1. Don't disable submit - Show errors instead
  2. Validate on blur - Immediate but not intrusive
  3. Pre-fill when possible - Reduce user effort
  4. Show password requirements - Before they fail
  5. Confirm destructive actions - Double-check deletes
  6. Save progress - For long forms, use localStorage

When to Use

  • User registration and login forms
  • Checkout and payment flows
  • Settings and profile forms
  • Data entry applications
  • Contact and feedback forms

Notes

  • react-hook-form is the most performant React form library
  • Zod provides runtime validation + TypeScript types
  • Test forms with screen readers and keyboard-only
  • Consider form analytics to find drop-off points