좋은 버튼은 한 모양이 아니라 다섯 상태를 가집니다. 기본은 ‘누를 수 있다’, 호버는 ‘여기다’, 로딩은 ‘지금 처리 중’, 완료는 ‘됐다’, 오류는 ‘이렇게 다시’를 말합니다. Findable은 이 다섯을 빠뜨리지 않게 설계해 중복 제출을 막고 오류에서 사용자를 갇히지 않게 합니다.
요약
- 버튼은 기본·호버·로딩·완료·오류 다섯 상태로 설계한다 — 한 모양으로 끝내지 않는다.
- 클릭하는 순간 로딩으로 바꾸고 disabled를 걸어 중복 제출을 막는다.
- 비활성은 흐리게만 하지 말고 ‘왜 못 누르는지’를 알려준다.
- 오류는 잠깐 보여준 뒤 ‘다시 시도’가 되는 기본 상태로 반드시 원복한다.
입사 둘째 해, 제가 만든 신청 폼에서 같은 문의가 3분 간격으로 두 번씩 들어온다는 리포트를 받았습니다. 코드는 멀쩡했죠. 문제는 ‘버튼이 아무 말도 안 한 것’이었습니다. 사용자가 신청을 눌렀는데 화면이 그대로니까, 안 눌린 줄 알고 한 번 더 누른 겁니다. 그날 저는 버튼을 ‘모양’이 아니라 ‘상태를 가진 작은 기계’로 다시 봤습니다.
그래서, 다섯 얼굴을 직접 바꿔보세요
설명보다 빠릅니다. 아래 위젯의 단계 버튼을 눌러 기본부터 오류까지 같은 버튼이 어떻게 바뀌는지 보세요.
버튼은 다섯 가지 얼굴을 가집니다
상태 버튼을 눌러 기본·호버·로딩·완료·오류를 확인하세요.
로딩 상태는 멋이 아니라 ‘중복 제출 방지 장치’입니다
클릭하는 순간 버튼을 로딩으로 바꾸면 두 가지가 동시에 일어납니다. 사용자에게는 ‘지금 처리 중’이라는 신호가 가고, 버튼에는 disabled가 걸려 같은 요청을 다시 보낼 수 없게 됩니다. 결제·예약·문의처럼 되돌릴 수 없는 행동 앞에서는 이걸 빼면 안 됩니다. 위 위젯에서 ‘로딩’을 눌러보면 버튼이 잠기는 게 보입니다.
비활성 버튼은 ‘왜 못 누르는지’까지 말해야 합니다
흔한 실수는 비활성 버튼을 그냥 흐리게만 만드는 겁니다. 사용자는 색이 옅어진 버튼을 보고도 ‘이게 안 눌리는 건가, 내가 잘못한 건가’를 모릅니다. 저는 비활성일 때 cursor를 not-allowed로 바꾸고, 가능하면 ‘필수 항목을 채워주세요’ 같은 이유를 옆에 붙입니다. 이유가 없는 비활성은 막다른 길처럼 느껴집니다.
완료 상태는 ‘봤다’와 ‘다음’ 사이의 짧은 1~2초입니다
체크 표시 같은 완료 신호는 사용자가 인지할 만큼만 보여줍니다. 보통 1~2초입니다. 너무 짧으면 못 보고, 너무 오래 멈춰 있으면 다음 행동을 가로막습니다. 완료를 보여준 뒤에는 다음 단계로 넘기거나 기본 상태로 원복해서 사용자가 갇히지 않게 합니다.
오류에서 멈추면 사용자는 갇힙니다 — 회복이 핵심
가장 자주 빠뜨리는 게 오류 회복입니다. 요청이 실패하면 버튼을 오류 상태로 잠깐 바꿔 ‘무엇이 잘못됐다’를 알리되, 반드시 다시 누를 수 있는 기본 상태로 돌아와야 합니다. 오류에서 멈춰버린 버튼은 사용자를 막다른 곳에 가둡니다. ‘다시 시도’가 가능하도록 원복하고, 잘못된 이유를 한 줄로 알려주는 것 — 이게 오류 설계의 전부입니다. 위 위젯의 ‘오류’를 눌러본 뒤 ‘기본’으로 돌아오는 흐름이 그것입니다.
다섯 상태를 코드로 묶는 방법: 상태 머신
저는 버튼 안에 data-state 하나만 둡니다. default → loading → success 또는 loading → error → default처럼 ‘허용된 전이’만 정의하면, 로딩 중에 또 클릭이 들어와도 무시되고 중복이 원천적으로 막힙니다. 위 위젯도 이 방식입니다. 상태가 머릿속이 아니라 코드에 있어야 버그가 줄어듭니다. 제가 실제로 쓰는 전이표는 이렇게 생겼습니다.
// 허용된 전이만 표로 선언 — 표에 없는 전이는 무시된다
const NEXT = {
default: ["loading"],
loading: ["success", "error"],
success: ["default"],
error: ["default", "loading"], // 오류 → 재시도 허용
};
function setState(btn, to) {
const from = btn.dataset.state || "default";
if (!NEXT[from]?.includes(to)) return; // 로딩 중 재클릭 = 차단
btn.dataset.state = to;
btn.disabled = (to === "loading"); // 중복 제출 원천 차단
btn.setAttribute("aria-busy", to === "loading");
}이렇게 표로 못 박아두면 ‘오류에서 멈춰버린 버튼’이나 ‘두 번 제출되는 신청’ 같은 사고가 코드 단계에서 사라집니다.
| 항목 | 상태 없는 버튼 | 상태 있는 버튼 |
|---|---|---|
| 중복 제출 | 로딩 표시 없음 → 두 번 클릭 | 클릭 즉시 로딩·disabled로 차단 |
| 신뢰 | 눌러도 반응 없음 → 불안 | 로딩·완료 신호로 ‘되고 있다’ 전달 |
| 오류 회복 | 실패하면 멈춤 → 갇힘 | 오류 표시 뒤 기본 상태로 원복·재시도 |
| 비활성 | 흐리게만 → 이유 모름 | not-allowed + 이유 안내 |
버튼 하나의 상태를 이렇게 보는 사람이, 사이트 전체를 만듭니다
버튼은 사용자가 ‘행동’하는 거의 유일한 지점이고, 그 뒤의 1초가 신뢰를 만듭니다. 그 다섯 상태를 진지하게 다루는 사람이라면 폼도 카피도 로딩 연출도 같은 태도로 다룹니다. Findable에서는 이런 상태 설계가 기본값입니다. 당신의 신청 버튼, 어떤 얼굴로 만들어 드릴까요?
다른 담당자와의 연결
버튼 기본기 — 8가지 특별한 버튼
상태 이전에, 버튼의 모양과 효과부터.
폼 검증 담당자의 노트
버튼을 누르기 전, 입력을 검증하는 사람.
로딩 연출 담당자의 노트
로딩 상태를 ‘기다릴 만하게’ 만드는 사람.
버튼을 누른 직후에 아무 변화도 없으면 왜 문제가 되나요?
로딩 중에 버튼을 비활성화하는 게 정말 필요한가요?
비활성(disabled) 버튼은 그냥 흐리게만 하면 되나요?
오류가 났을 때 버튼은 어떻게 회복해야 하나요?
완료 상태는 얼마나 오래 보여줘야 하나요?
버튼 하나에 며칠을 씁니다
8가지 특별한 버튼을 직접 눌러보는 글.
폼 검증 담당자의 노트
실시간 검증과 친절한 오류 메시지.
로딩 연출 담당자의 노트
기다림을 줄여 보이게 하는 로딩 설계.
이 글의 상태 위젯은 이 페이지에서 실제로 동작하는 코드입니다(기본·호버·로딩·완료·오류 다섯 상태, 외부 라이브러리 0). 전환·중복 제출 관련 서술은 일반 원칙이며 특정 성과를 보장하지 않습니다. 날조된 사례·수치는 사용하지 않았습니다.