Forms That Feel Good

Lesson 5

Forms are where UX lives. A well-built form has good defaults, clear error states, accessible labels, and micro-interactions that feel polished. Claude Code can handle all of it.

Starting prompt

로그인 폼을 만들어줘.
- 이메일 + 비밀번호 필드
- "로그인" 버튼 (primary, full-width)
- "비밀번호 찾기" 텍스트 링크
- 구분선 + "Google로 계속하기" 소셜 버튼
- 흰 카드, max-width 400px, center on page
- Pretendard 폰트 사용

Adding validation

Once you have the structure:

이메일 필드에 blur 이벤트로 유효성 검사 추가해줘.
- 형식이 맞지 않으면 필드 아래에 에러 메시지 표시
- 에러 시 border-color: #ef4444
- 성공 시 border-color: #22c55e
- 메시지는 role="alert" aria 속성 포함

Password toggle

비밀번호 필드 오른쪽 안쪽에 표시/숨김 토글 버튼 추가해줘.
- 눈 아이콘 SVG (inline, 16px)
- 클릭하면 type="password" ↔ type="text" 전환
- 버튼은 배경 없음, border 없음

Loading state

로그인 버튼 클릭 시 로딩 상태 추가해줘.
- 텍스트 → 스피너 (CSS border-radius spin animation)
- 클릭 중 버튼 disabled
- 2초 후 원래 상태로 (시뮬레이션용)

What the demo shows

The demo is the finished login form — with validation, password toggle, social button, and loading state — exactly what you’d build through the prompts above. Read the source code to see how all the pieces fit together.

Source Code script.js
// ── Password toggle ───────────────────────────────────────
const pwInput = document.getElementById('password');
const pwToggle = document.getElementById('pwToggle');
const eyeIcon = document.getElementById('eyeIcon');

pwToggle.addEventListener('click', () => {
  const show = pwInput.type === 'password';
  pwInput.type = show ? 'text' : 'password';
  pwToggle.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
  eyeIcon.innerHTML = show
    ? `<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
       <path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
       <line x1="1" y1="1" x2="23" y2="23"/>`
    : `<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>`;
});

// ── Email validation ──────────────────────────────────────
const emailInput = document.getElementById('email');
const emailField = document.getElementById('emailField');
const emailMsg = document.getElementById('emailMsg');

function validateEmail() {
  const v = emailInput.value.trim();
  if (!v) {
    setFieldState(emailField, emailMsg, '', '');
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
    setFieldState(emailField, emailMsg, 'error', 'Please enter a valid email address');
  } else {
    setFieldState(emailField, emailMsg, 'success', '');
  }
}
emailInput.addEventListener('blur', validateEmail);
emailInput.addEventListener('input', () => {
  if (emailField.classList.contains('error')) validateEmail();
});

// ── Password validation ───────────────────────────────────
const passwordField = document.getElementById('passwordField');
const passwordMsg = document.getElementById('passwordMsg');

function validatePassword() {
  const v = pwInput.value;
  if (!v) {
    setFieldState(passwordField, passwordMsg, '', '');
  } else if (v.length < 6) {
    setFieldState(passwordField, passwordMsg, 'error', 'Password must be at least 6 characters');
  } else {
    setFieldState(passwordField, passwordMsg, 'success', '');
  }
}
pwInput.addEventListener('blur', validatePassword);
pwInput.addEventListener('input', () => {
  if (passwordField.classList.contains('error')) validatePassword();
});

function setFieldState(field, msgEl, state, msg) {
  field.classList.remove('error', 'success');
  if (state) field.classList.add(state);
  msgEl.textContent = msg;
}

// ── Form submit with loading state ───────────────────────
const form = document.getElementById('loginForm');
const submitBtn = document.getElementById('submitBtn');

form.addEventListener('submit', e => {
  e.preventDefault();

  validateEmail();
  validatePassword();

  const hasError = emailField.classList.contains('error') || passwordField.classList.contains('error');
  const isEmpty = !emailInput.value.trim() || !pwInput.value;

  if (hasError || isEmpty) {
    if (!emailInput.value.trim()) setFieldState(emailField, emailMsg, 'error', 'Email is required');
    if (!pwInput.value) setFieldState(passwordField, passwordMsg, 'error', 'Password is required');
    return;
  }

  // Simulate API call
  submitBtn.disabled = true;
  submitBtn.classList.add('loading');

  setTimeout(() => {
    submitBtn.disabled = false;
    submitBtn.classList.remove('loading');
    // In a real app: redirect on success
    setFieldState(emailField, emailMsg, 'error', 'Invalid email or password');
    setFieldState(passwordField, passwordMsg, 'error', ' ');
  }, 2000);
});