폼 입력
데이터를 입력하고 선택지를 고르는 컴포넌트.
폼은 Senco가 사용자에게 정확한 입력을 요구하는 곳이다. 계좌 번호, 금액, 일회용 코드를 대개 휴대폰으로, 연결이 불안한 상태에서, 모국어가 아닌 언어로 입력한다. 이 페이지의 규칙은 모두 화면을 조금 덜 간결하게 만드는 대신, 돈이 엉뚱한 곳으로 갈 가능성을 줄인다.
컴포넌트
| Component | Purpose |
|---|---|
| Input | 한 줄 텍스트 (text, email, tel, password, number, search, URL) |
| Textarea | 여러 줄 텍스트 입력 |
| Select | 드롭다운에서 옵션 하나를 고른다 |
| Checkboxes | 펼쳐 놓은 옵션 중 하나 이상을 고른다 |
| Radio group | 펼쳐 놓은 옵션 중 정확히 하나를 고른다 |
| Radio card | 카드 형태로 하나를 고른다 (결제 수단, 경로 비교) |
| Date input | 일/월/년을 나눠 입력하는 필드 |
| Switch | 설정을 켜고 끄는 토글 |
| Toggle | 눌러서 상태를 켜고 끄는 버튼 (굵게, 기울임, 보기 모드) |
| Search select | 긴 목록에서 검색해 하나를 고르는 드롭다운 |
| Multi select | 검색해 여러 개를 고르고 칩으로 보여 주는 드롭다운 |
| Input OTP | 일회용 비밀번호를 한 자리씩 입력하는 필드 |
| File uploader | 끌어다 놓거나 찾아보기로 파일을 올린다 |
| Date picker | 직접 입력도 되는 달력 팝오버 |
| Currency input | 통화 기호와 코드를 붙여 서식을 맞추는 금액 입력 |
| Phone input | 국가 코드 선택기가 붙은 전화번호 입력 |
| Password input | 보기/숨기기 토글, 필요하면 강도 표시 |
| Masked input | 입력하면서 서식이 맞춰지는 필드 (카드 번호, 만료일, CVV) |
| Input group | 앞뒤에 텍스트나 컨트롤을 붙인 입력 |
| Form field | 라벨, 입력, 도움말, 에러 메시지를 하나로 묶는다 |
공통 규칙
이 페이지의 모든 컴포넌트에 해당한다. 컴포넌트마다 열아홉 번 반복하지 않고 여기에 한 번 적는다.
| Rule | Why |
|---|---|
| 라벨은 항상 필드 위에 보이게 둔다 | placeholder는 입력을 시작하면 사라진다. 사용자는 이 필드가 무엇을 묻는지 다시 확인할 수 없다 |
| placeholder를 라벨 대신 쓰지 않는다 | SC 3.3.2에 걸리고, 폼을 채우다 잠시 자리를 비운 사람은 길을 잃는다 |
| 컨트롤 높이는 최소 48px | --ds-input-height. 터치 기준은 필드에도 똑같이 적용된다 |
| 에러는 색, 아이콘, 텍스트로 함께 표시한다 | 빨간색만으로는 SC 1.4.1에 걸린다 |
에러에는 항상 aria-invalid + aria-describedby | 메시지가 옆에 보이기만 해서는 안 되고 필드에 연결되어야 한다 |
| 필수 여부는 색이 아니라 라벨로 표시한다 | 별표와 그 뜻을 알려 주는 안내, 또는 나머지 필드에 "optional" 표기 |
| 검증은 blur 때 한다. 키 입력마다 하지 않는다 | 두 글자 쳤는데 이메일이 틀렸다고 알리는 건 소음이다 |
| 검증 에러가 나도 입력값은 지우지 않는다 | 입력해 둔 계좌 번호가 날아가는 게 에러보다 더 나쁘다 |
관련 필드는 <fieldset>과 <legend>로 묶는다 | 라디오 그룹에 이름을 붙일 방법은 legend뿐이다 |
공통 상태
| State | Field | Token |
|---|---|---|
| Default | 1px --ds-border | --ds-input-height |
| Hover | 테두리 --ds-gray-400 | — |
| Focus-visible | 테두리 --ds-primary, 20% 불투명도의 3px 링, 오프셋 없음 | --ds-focus-ring |
| Filled | Default와 같다 | — |
| Error | 기본 상태에서 테두리 --ds-red-600, 배경 --ds-error-bg. 에러 링은 focus-visible일 때만 더한다 | --ds-error-bg · --ds-focus-ring-error |
| Disabled | --ds-disabled-bg / --ds-disabled-text | aria-disabled |
| Read-only | 기본 테두리, --ds-surface 배경. 포커스와 텍스트 선택은 그대로 된다 | — |
Read-only와 disabled는 다르다. 읽기 전용 필드는 포커스를 받고, 스크린 리더가 읽고, 복사할 수 있다. 사용자가 은행 앱에 붙여 넣어야 하는 참조 번호라면 이 차이가 중요하다.
공통 구조
이 페이지의 컨트롤은 모두 같은 요소를 같은 순서로 위에서 아래로 쌓는다. 각 컴포넌트의 구조 섹션에는 여기에 더해지는 것만 적는다.
Label * --ds-text-sm / 600, --ds-heading, required marker in the text
Hint --ds-text-xs, --ds-muted — read before typing
┌──────────────────────────────┐
│ Value │ --ds-input-height (48px), --ds-text-base, --ds-border, --ds-radius-md
└──────────────────────────────┘
⚠ Error --ds-text-sm, --ds-error-text — below the control; the hint stays
| Part | Required | Token |
|---|---|---|
| Label | Yes | --ds-text-sm, --ds-font-semibold, --ds-heading |
| Hint | No | --ds-text-xs, --ds-muted |
| Control | Yes | --ds-input-height, --ds-border, --ds-radius-md |
| Value | Yes | --ds-text-base, --ds-fg |
| Placeholder | No | --ds-placeholder, 대비 라이트 4.50:1, 다크 5.58:1 |
| Error | No | --ds-text-sm, --ds-error-text, --ds-error-icon 16px |
요소 사이 간격은 모두 --ds-space-2다.
힌트는 컨트롤 위에 두고, 에러가 나도 그대로 둔다. 형식 힌트가 필드 아래에 있으면 실수한 다음에야 읽힌다. 에러가 날 때 힌트를 숨기면 고치는 방법을 알려 주는 유일한 문장이 사라진다. 에러는 지금 입력된 값에 대한 것이라 컨트롤 아래에 둔다. aria-describedby에는 에러를 먼저, 힌트를 나중에 넣어서 무엇을 고쳐야 하는지가 먼저 읽히게 한다.
에러는 라이브 영역이 아니다. 사용자가 필드로 돌아왔을 때 읽힌다. blur 때마다 소리 내 알리면 아직 폼을 채우고 있는 사람을 방해한다. Validation 참고.
공통 토큰
--ds-input-height · --ds-input-height-sm · --ds-border · --ds-radius-md · --ds-heading · --ds-fg · --ds-text-base · --ds-focus-ring · --ds-focus-ring-error · --ds-disabled-bg · --ds-disabled-text · --ds-muted · --ds-error-bg · --ds-error-text · --ds-error-icon · --ds-text-sm · --ds-text-xs · --ds-space-2 · --ds-space-4
각 컴포넌트의 토큰 섹션에는 여기에 더해지는 것만 적는다.
선택 컴포넌트 고르기
| Options | Component |
|---|---|
| 2–3개, 라벨이 짧다 | Radio group, 가로 |
| 4–6개 | Radio group, 세로 |
| 4–6개, 비교할 세부 정보가 있다 | Radio card |
| 7–15개 | Select |
| 15개 초과 | Search select |
| 많은 옵션 중 여러 개 | Multi select |
| 바로 적용되는 켜기/끄기 설정 하나 | Switch |
| 하나 이상, 전부 펼쳐 둔다 | Checkboxes |
Input
한 줄 텍스트 입력. 모든 입력에는 필드 위에 보이는 라벨이 있어야 한다.
text,email,tel,password,number,search,url유형을 지원한다- 에러 메시지는 입력 아래에 나오고
aria-describedby로 연결한다 - 필드 폭은 들어갈 내용의 길이에 맞춘다
구조
Label * ← --ds-text-sm / 600, required marker
Helper text ← --ds-text-xs, --ds-muted
┌──────────────────────────────┐
│ Value │ ← 48px, --ds-border, --ds-radius-md
└──────────────────────────────┘
⚠ Error message ← --ds-text-sm, --ds-error-text; helper stays
변형
| Variant | Dimension | Use |
|---|---|---|
| Default | layout | width="full" |
| Content-width | layout | 예상 길이에 맞춘 폭. 3자리 CVV에 전체 폭을 쓰지 않는다 |
API
이 페이지의 컨트롤은 모두 같은 기본 인터페이스를 확장한다. 여기에 한 번 정의하고, 각 컴포넌트의 인터페이스는 이를 참조한다.
interface FieldBase {
id?: string; // generated when omitted
label: string; // visible, above the field — required, never a placeholder
hint?: string; // format guidance, rendered between the label and the control
error?: string; // rendered below the control; the hint stays; sets aria-invalid and aria-describedby
required?: boolean; // marked in the label text, never by colour alone
disabled?: boolean; // aria-disabled; the control stays focusable
readOnly?: boolean; // focusable and selectable — not the same as disabled
name?: string;
onBlur?: () => void; // validation runs on blur — every field needs the hook
}
interface InputProps extends FieldBase,
Omit<React.InputHTMLAttributes<HTMLInputElement>, "id" | "required" | "disabled" | "readOnly"> {
type?: "text" | "email" | "tel" | "url" | "search"; // "number" is never used — see Behavior
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
autoComplete?: string; // required wherever a standard token applies
value: string;
onValueChange: (value: string) => void;
width?: "full" | "content"; // default: "full" — "content" sizes to the expected length
}
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Autofilled | 브라우저 기본 배경을 그대로 둔다. 테두리와 포커스 링은 유지한다. -webkit-autofill로 덮어써서 지우지 않는다 |
| Read-only | --ds-surface 배경, 포커스와 텍스트 선택은 그대로 된다. 사용자가 복사해야 하는 참조 번호에 쓴다 |
동작
type과inputmode는 검증용으로만 고르지 않는다. 어떤 키보드가 뜨는지를 보고 고른다. 계좌 번호에inputmode="numeric"을 쓰면 필드마다 탭이 네 번 줄어든다.- 표준 토큰(
name,tel,email,postal-code)에 해당하는 필드에는 모두autocomplete를 단다. 자동 완성은 편의 기능이 아니라 접근성 기능이다. - 필드 폭으로 예상 길이를 알려 준다. 3자리 CVV 필드에 전체 폭을 쓰지 않는다. 내용에 맞춘 폭은 특정 유형의 입력 실수를 통째로 막아 준다.
type="number"대신inputmode="decimal"을 쓴다. number 입력은 스피너 화살표가 붙고,e를 받고, 앞자리 0을 말없이 지운다.
콘텐츠
라벨은 무엇을 입력하는지 말한다. 힌트는 형식 조건을 입력 후가 아니라 입력 전에 알려 준다.
접근성
- 라벨은 입력
id에 연결한<label for>로 만든다. 위에<div>를 올려 두는 게 아니다. - 도움말과 에러는 모두
aria-describedby로 참조한다. - 에러가 나도 도움말은 그대로 둔다.
aria-describedby에는 에러를 먼저, 도움말을 나중에 넣어서 무엇을 고쳐야 하는지가 먼저 읽히게 한다.
키보드
| Key | Action |
|---|---|
Tab | 필드로 포커스를 옮기거나 빠져나간다 |
Esc | type="search"일 때 입력을 지운다 |
토큰
공통 필드 토큰을 쓴다.
- 1Container / border
- 2Label
- 3Placeholder text
- 4Helper text
- 5Error icon
- 6Error message
Select
드롭다운에서 옵션 하나를 고른다.
- 처음 보이는 빈 옵션에 무엇을 고를지 안내한다 ("Select a reason")
- 옵션이 15개를 넘으면 Search select를 쓴다
구조
공통 필드 구조를 따른다. inline-end에서 --ds-space-4 들어간 자리에 --ds-muted 16px 화살표를 두고, 값이 화살표에 겹치지 않도록 컨트롤 끝에 --ds-space-8 패딩을 준다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Plain | layout | 옵션 7–15개, 그룹 없음 |
| Grouped | layout | suggestedOptions를 맨 앞 <optgroup>으로 렌더링한다 |
API
interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps extends FieldBase {
options: SelectOption[];
suggestedOptions?: SelectOption[]; // rendered first in their own optgroup; the component does the grouping
groupLabels?: { suggested: string; all: string }; // default: { suggested: "Suggested", all: "All options" }
value: string | null;
onValueChange: (value: string) => void;
placeholder?: string; // default: "Select an option" — an instruction, not a blank row
}
| Decision | Contract |
|---|---|
| 요소 | 터치 기기만이 아니라 모든 기기에서 네이티브 <select>를 쓴다. "모바일에서는 네이티브"와 "가능하면 네이티브"가 부딪히는 문제는 어디서나 네이티브로 정리한다 |
| 정렬 | 컴포넌트는 suggestedOptions를 먼저 묶어 보여 주고, 이어서 options를 받은 순서대로 렌더링한다. 각 그룹 안의 정렬은 호출하는 쪽이 맡는다 |
| 15개 상한 | 가이드라인일 뿐 강제하지 않는다. 15개를 넘으면 Search select를 쓴다. 컴포넌트가 막지는 않는다 |
readOnly | 네이티브 <select>에는 readonly 속성이 없다. aria-readonly를 달고 값이 바뀌면 되돌린다. 이 요소로 읽기 전용 상태를 표현할 방법은 이것뿐이다 |
disabled | <select>에 aria-disabled="true"를 달고 포커스는 계속 받는다. readOnly처럼 값이 바뀌면 되돌린다. 네이티브 disabled 속성은 쓰지 않는다 |
required | aria-required="true"만 단다. 네이티브 required 속성은 쓰지 않는다. 브라우저 말풍선이 에러 요약과 겹친다 |
| Placeholder 옵션 | <option value="" disabled>로 렌더링하고, value가 null이면 이 옵션이 선택된다. 다시 고를 수 없으므로 Select는 값을 비울 수 없다. 비우는 동작이 필요하면 "None" 같은 옵션을 따로 넣는다 |
| 그룹 | suggestedOptions가 비어 있지 않을 때만 렌더링한다. 추천용 <optgroup> 하나, 전체용 하나를 두고 라벨은 groupLabels에서 가져온다 |
| 화살표 | 네이티브 화살표는 appearance: none으로 숨긴다. 대신 --ds-muted 16px 화살표를 inline end에서 --ds-space-4 들어간 자리에 두고, 필드 끝에 --ds-space-8 패딩을 줘서 값과 겹치지 않게 한다 |
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Unselected | 빈 행 대신 안내 문구로 placeholder를 보여 준다. value는 ""가 아니라 null이다 |
| Open | 열린 상태는 OS 선택기가 그린다. 컴포넌트는 팝오버를 렌더링하지 않는다 |
| Read-only | aria-readonly를 달고 값이 바뀌면 되돌린다. 네이티브 요소에는 readonly 속성이 없다 |
동작
- 빈 옵션은 빈칸이 아니라 안내로 읽혀야 한다. "Select a reason"은 무엇을 할지 알려 주지만, 비어 있는 첫 행은 그 자체로 고를 수 있는 값처럼 보인다.
- 옵션은 자주 쓰일 순서로, 그다음 알파벳순으로 정렬한다. 국가 목록이라면 사용자에게 추천하는 국가를 맨 위에 둔다.
- 모바일에서는 네이티브
<select>를 쓴다. OS 선택기가 더 빠르고 익숙하며, 접근성도 따로 챙길 필요가 없다. 터치 환경의 커스텀 드롭다운은 거의 언제나 이보다 못하다.
콘텐츠
placeholder는 "Select a reason" 같은 안내 문구이고, 선택 가능한 값이 아니다.
접근성
가능하면 네이티브 <select>를 쓴다. 커스텀 listbox를 만들면 타이핑 이동, 화살표 탐색, Home/End, 스크린 리더 안내를 전부 다시 구현해야 한다. 그중 늘 빠지는 게 타이핑 이동이다.
키보드
네이티브 <select>의 키보드 동작을 그대로 쓴다. 네이티브를 고른 이유이기도 하다.
| Key | Action |
|---|---|
Space / ↓ | 선택기를 연다 |
↑ ↓ | 옵션 사이를 이동한다 |
| 타이핑 이동 | 입력한 글자로 시작하는 첫 옵션으로 간다 |
Esc | 값을 바꾸지 않고 닫는다 |
토큰
공통 필드 토큰을 쓴다.
Checkboxes
펼쳐 놓은 옵션 중 하나 이상을 고른다.
- 관련된 체크박스는
<fieldset>과 legend로 묶는다 - "전체 선택" 패턴에 쓰는 indeterminate 상태를 지원한다
구조
fieldset과 legend 아래에 옵션마다 한 행을 둔다. 행의 inline-start에 16px 상자가 있고, 라벨이 48px 행 전체를 채운다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Group | selection-mode | legend 아래에 관련 옵션이 둘 이상 |
| Standalone | selection-mode | 동의나 확인 하나 |
API
interface CheckboxGroupProps {
legend: string; // the question the group answers — required
options: SelectOption[];
value: string[];
onValueChange: (value: string[]) => void;
selectAll?: boolean; // renders a parent with indeterminate support
}
interface CheckboxProps extends Omit<FieldBase, "hint"> {
checked: boolean | "indeterminate";
onCheckedChange: (checked: boolean) => void;
}
"indeterminate"는 부모 체크박스에만 나타나는 표시 상태다. 사용자가 직접 고를 수 있는 값이 아니다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Indeterminate | 부모에만 쓴다. DOM 속성과 aria-checked="mixed"로 표현한다. 사용자가 직접 고를 수 있는 값이 아니다 |
동작
- 체크박스마다 라벨 덕분에 48px 터치 영역이 생긴다. 16px 상자뿐 아니라 라벨 행 전체를 누를 수 있다.
- indeterminate는 부모에만 나타나는 시각 상태다. 사용자가 직접 고를 수 있는 값이 아니다.
- 체크박스가 하나뿐일 때(동의, 약관)도 라벨은 "I agree"가 아니라 동의하는 내용 전체를 적는다.
콘텐츠
단독 체크박스의 라벨에는 동의하는 내용을 문장으로 다 적는다. "I agree"로 끝내지 않는다.
접근성
- 그룹 이름은
<legend>가 붙인다. legend가 없으면 스크린 리더는 서로 관계없는 체크박스 네 개로 읽는다. - indeterminate 상태는
indeterminateDOM 속성과aria-checked="mixed"를 함께 쓴다.
키보드
| Key | Action |
|---|---|
Tab | 체크박스마다 따로 탭으로 멈춘다. 라디오 그룹과 다르다 |
Space | 체크를 켜고 끈다 |
토큰
공통 필드 토큰에 더해, 체크된 상자의 배경에 --ds-primary를 쓴다.
Date input
일, 월, 년을 각각 다른 필드에 나눠 날짜를 입력한다.
- 힌트에 입력 형식을 보여 준다 (예: "15 03 2026")
- 필드마다 따로 검증한다
- 생년월일과 신분증 날짜에는 date picker보다 이쪽을 쓴다
생년월일에 필드가 셋인 이유
달력 선택기로 생년월일 1987년까지 가려면 탭을 마흔 번 가까이 해야 한다. 숫자 필드 세 개면 여덟 번이고, 사용자가 보고 옮겨 적는 서류에 날짜가 적힌 방식과도 그대로 맞는다. Date picker는 날짜를 고를 때, date input은 날짜를 옮겨 적을 때 쓴다.
구조
공통 필드 구조를 따른다. 컨트롤은 숫자 필드 세 개이고, 각각 Day, Month, Year 하위 라벨이 보인다.
변형
하나.
API
interface DateInputProps extends Omit<FieldBase, "label"> {
legend: string; // "Date of birth"
value: { day: string; month: string; year: string };
onValueChange: (value: { day: string; month: string; year: string }) => void;
autoAdvance?: boolean; // default: true — a convenience, never a requirement
}
필드마다 라벨이 보인다. 빈 필드에서 백스페이스를 누르면 포커스가 앞 필드로 돌아간다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Partially filled | 필드마다 따로 검증한다. 그룹 에러는 마지막 필드에서 blur할 때만 띄운다 |
동작
각 필드는 blur 때 따로 검증하고, 그룹 전체는 마지막 필드에서 blur할 때 검증한다. 필드가 다 채워지면 포커스가 다음 필드로 넘어가고, 빈 필드에서 백스페이스를 누르면 앞 필드로 돌아간다.
콘텐츠
힌트에는 입력 순서대로 채운 예시를 보여 준다. "15 03 2026".
접근성
<fieldset>으로 감싸고,<legend>에 필드 이름("Date of birth")을 넣는다.- 각 필드에는 placeholder가 아니라 눈에 보이는 라벨("Day", "Month", "Year")을 단다.
- 다음 필드로 자동으로 넘어가는 건 편의 기능일 뿐, 그것에 기대면 안 된다. 빈 필드에서 백스페이스를 누르면 앞 필드로 돌아간다.
- 형식 힌트는 그룹의
aria-describedby로 참조한다.
키보드
| Key | Action |
|---|---|
Tab | 일, 월, 년 필드 사이를 이동한다 |
빈 칸에서 Backspace | 앞 필드로 돌아간다 |
토큰
공통 필드 토큰을 쓴다.
Switch
켜는 즉시 적용되는 설정에 쓰는 켜기/끄기 토글.
- 바로 반영되는 설정에는 switch를, 폼을 제출해야 반영되는 값에는 checkbox를 쓴다
크기
| Size | Track (W x H) | Thumb | Use case |
|---|---|---|---|
sm | 24 x 14px | 12px | 빽빽한 목록, 설정 행 |
default | 32 x 18px | 16px | 일반 폼 |
lg | 48 x 28px | 24px | 눈에 잘 띄어야 하는 토글. WCAG 2.2 AA 타깃 크기를 충족한다 |
구조
라벨은 inline-start, 트랙과 썸은 inline-end에 둔다. 눈에 보이는 트랙은 실제 터치 영역인 48px 행보다 작다.
변형
| Variant | Dimension | Track | Use |
|---|---|---|---|
sm | layout | 24×14 | 빽빽한 설정 행 |
default | layout | 32×18 | 일반 폼 |
lg | layout | 48×28 | 눈에 잘 띄어야 하는 토글. 트랙만으로도 AA 타깃 크기를 충족한다 |
API
interface SwitchProps extends Omit<FieldBase, "hint"> {
checked: boolean;
onCheckedChange: (checked: boolean) => Promise<void> | void;
size?: "sm" | "default" | "lg"; // default: "default"
onError?: (error: Error) => void; // a failed toggle reverts *and* reports
}
Switch는 바로 적용되고 저장 단계가 없다. 제출할 때 반영되는 컨트롤이라면 Checkbox를 쓴다. 라벨에는 현재 상태가 아니라 설정 이름을 적는다.
상태
| State | Visual | Notes |
|---|---|---|
| Off | 트랙 --ds-gray-300, 썸은 inline-start | |
| On | 트랙 --ds-primary, 썸은 inline-end | |
| Hover | 트랙을 한 단계 어둡게 | |
| Focus-visible | 트랙에 3px 실선 --ds-focus 외곽선, 2px 오프셋 | |
| Disabled | --ds-disabled-bg, aria-disabled | |
| Pending | 썸에 인라인 로딩 표시. 트랙은 이전 위치에 둔다 | 요청이 끝난 뒤에야 바뀐 상태로 보여 준다 |
| Failed | 이전 위치로 돌아가고, 동시에 에러를 보여 준다 | 말없이 되돌아가는 스위치는 사용자에게 아무 정보도 주지 않는다 |
동작
- 스위치는 바로 적용되고 저장 버튼이 없다. 저장 단계가 있다면 체크박스를 써야 한다.
- 실패는 반드시 알린다. 요청이 실패해서 메시지 없이 되돌아가기만 하는 스위치는 사용자에게 아무 정보도 주지 않는다. 실패하면 되돌리고, 동시에 에러를 보여 준다.
- 모든 크기에서 트랙은 터치 영역보다 작게 보인다.
sm도 행 전체가 48px 터치 영역이 된다.
콘텐츠
라벨에는 상태가 아니라 설정 이름을 적는다. "Email notifications are on"이 아니라 "Email notifications".
접근성
- checkbox 역할이 아니라
role="switch"와aria-checked를 쓴다. 스크린 리더가 "checked" 대신 "on"/"off"로 읽는다. - 라벨에는 현재 상태가 아니라 설정이 무엇을 하는지를 적는다. "Email notifications are on"이 아니라 "Email notifications".
키보드
| Key | Action |
|---|---|
Tab | 포커스를 옮긴다 |
Space | 켜고 끈다 |
토큰
공통 필드 토큰에 더해, 켜진 트랙에 --ds-primary, 꺼진 트랙에 --ds-gray-300을 쓴다.
Toggle
누를 때마다 켜짐과 꺼짐이 바뀌는 버튼. 서식 옵션이나 보기 전환에 쓴다. 아이콘만 쓰거나 아이콘과 텍스트를 함께 쓸 수 있다.
구조
버튼 하나로 이뤄진다. inline-start에 아이콘, 그 옆에 라벨을 둘 수 있고 둘 다 선택 사항이다. 내용과 상관없이 터치 영역은 48px이다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Icon + label | layout | 기본 |
| Icon only | layout | 접근 가능한 이름으로 쓸 label이 꼭 있어야 한다 |
API
interface ToggleProps {
pressed: boolean;
onPressedChange: (pressed: boolean) => void;
label: string; // names the function ("Bold"); unchanged between states
icon?: React.ReactNode;
showLabel?: boolean; // default: true
}
상태
| State | Visual |
|---|---|
| Unpressed | --ds-bg, --ds-muted |
| Pressed | --ds-primary-light 배경, --ds-primary, 굵기 600 |
| Hover | 눌리지 않은 상태에서만 --ds-surface |
| Focus-visible | --ds-focus-ring |
| Disabled | --ds-disabled-text |
동작
바로 적용된다. 제출할 때 반영되어야 한다면 Checkbox를 쓴다.
콘텐츠
라벨에는 기능 이름을 적고, 상태가 바뀌어도 라벨은 그대로 둔다.
접근성
<button>에 aria-pressed를 단다. 아이콘만 있는 토글에는 기능 이름을 담은 aria-label("Bold")이 꼭 있어야 한다. 켜짐/꺼짐은 눌림 상태가 전달하므로 라벨은 상태가 바뀌어도 그대로다.
키보드
| Key | Action |
|---|---|
Tab | 포커스 이동 |
Space / Enter | 켜고 끈다 |
토큰
--ds-primary-light · --ds-primary · --ds-surface · --ds-focus-ring · --ds-radius-md
Textarea
메모, 메시지, 설명처럼 여러 줄로 쓰는 텍스트 입력.
- 기본으로 세로 크기 조절이 된다
- 최대 길이가 있는 필드에는 글자 수를 보여 준다
구조
공통 필드 구조를 따른다. 컨트롤 아래 inline-end에 글자 수가 붙는다.
변형
하나. rows와 resize는 prop으로 조절한다.
API
interface TextareaProps extends FieldBase {
value: string;
onValueChange: (value: string) => void;
rows?: number; // default: 3 — a one-row textarea invites a one-line answer
maxLength?: number; // renders a live count; overflow is allowed and blocks submit
resize?: "vertical" | "none"; // default: "vertical"
}
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Near limit | maxLength의 90%가 되면 글자 수가 --ds-warning-text로 바뀐다 |
| Over limit | 글자 수는 --ds-error-text. 입력은 계속 받고, 제출만 막는다 |
동작
- 글자 수는 실시간으로 세고, 한도에 가까워지면 경고로 바뀐다. 스크린 리더에는 polite로 알리고, 키 입력마다 읽지 않는다.
- 한도에서 입력을 강제로 자르지 않는다. 아무 표시 없이 입력이 막히면 키보드가 고장 난 것과 구분이 안 된다. 한도를 넘겨도 입력은 받고, 글자 수를 보여 주고 제출만 막으면 사용자가 스스로 고칠 수 있다.
- 최소 세 줄. 한 줄짜리 textarea는 일반 텍스트 입력처럼 보여서 한 줄로만 답하게 된다.
콘텐츠
힌트에는 이 내용이 어디에 쓰이는지 적는다. 왜 쓰는지 모르는 자유 입력 필드는 대개 빈칸으로 남는다.
접근성
글자 수는 aria-describedby로 참조한다. 키 입력마다 읽지 않고, 경고 구간에 들어설 때 polite로 알린다.
키보드
| Key | Action |
|---|---|
Tab | 포커스를 밖으로 옮긴다. 탭 문자를 넣지 않는다 |
Enter | 줄을 바꾼다. 폼을 제출하지 않는다 |
토큰
공통 필드 토큰을 쓴다.
Max 200 characters.
0/200Radio group
펼쳐 놓은 옵션 중 정확히 하나를 고른다.
- 기본은 세로 배치. 짧은 옵션 2–3개는 가로로 놓는다
- 대부분이 고르는 옵션이 있으면 미리 선택해 둔다
구조
fieldset과 legend 아래에 옵션마다 한 행을 둔다. 행의 inline-start에 16px 원이 있고, 라벨이 48px 행 전체를 채운다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Vertical | layout | 기본. 옵션 4–6개 |
| Horizontal | layout | 짧은 옵션 2–3개 |
API
interface RadioGroupProps extends Omit<FieldBase, "label"> {
legend: string;
options: SelectOption[];
value: string | null;
onValueChange: (value: string) => void;
orientation?: "vertical" | "horizontal"; // default: "vertical"; horizontal for 2–3 short options
}
라디오 그룹은 한 번 고르면 선택을 해제할 방법이 없다. 선택 안 함이 의미 있는 경우에는 "None" 옵션을 따로 넣는다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Unselected | 처음 고르기 전에만 있는 상태다. 그 뒤로는 선택을 해제할 수 없으므로, 선택 안 함이 의미 있으면 "None" 옵션을 넣는다 |
동작
- 대부분의 사용자가 실제로 그 옵션을 고를 때만 미리 선택한다. 수수료가 걸린 선택지를 미리 골라 두면 사용자 대신 결정을 내린 셈이 된다.
- 라디오 그룹은 한 번 고르면 해제할 수 없으므로, 선택 안 함이 의미 있는 곳에서는 "None"을 옵션으로 넣는다.
콘텐츠
옵션은 서로 겹치지 않고, 같은 형식으로 쓴다. 두 옵션이 동시에 해당될 수 있다면 체크박스 그룹을 써야 한다.
접근성
- 그룹 전체에서 Tab은 한 번만 멈춘다. 화살표 키로 옵션 사이를 이동하면 선택도 함께 바뀐다.
- 그룹 이름은
<fieldset>+<legend>가 붙인다. - 16px 원뿐 아니라 라벨이 있는 48px 행 전체를 누를 수 있다.
키보드
그룹 전체에서 Tab은 한 번만 멈춘다.
| Key | Action |
|---|---|
Tab | 그룹으로 들어가거나 빠져나간다 |
← → ↑ ↓ | 이동하면서 바로 선택한다. 표준 라디오 동작이다 |
Home / End | 첫 옵션 / 마지막 옵션 |
토큰
공통 필드 토큰에 더해, 선택된 원의 채움에 --ds-primary를 쓴다.
Radio card
일반 라디오 버튼보다 옵션 하나하나를 더 크게 보여 주는 단일 선택. 키보드 동작은 RadioGroup을 그대로 따른다.
구조
카드 컨테이너 안에 inline-start 표시, 아이콘, 라벨, 설명이 들어가고, 필요하면 배지와 구분선 아래 세부 행을 더한다.
변형
| Variant | Use case | Icon size |
|---|---|---|
compact | 단순한 목록 (결제 수단, 신분증 종류, 코인) | 32px |
expanded | 세부 행으로 비교하는 선택 (송금 경로, 요금제) | 40px |
API
interface RadioCardOption {
value: string;
label: string;
description?: string;
icon?: React.ReactNode;
badge?: "Cheapest" | "Fastest" | "Recommended"; // computed, never assigned by hand
details?: Array<{ label: string; value: string }>; // expanded variant only
disabled?: boolean;
}
interface RadioCardGroupProps {
legend: string;
variant?: "compact" | "expanded"; // default: "compact"; never mixed within a group
options: RadioCardOption[]; // 6 maximum
value: string | null;
onValueChange: (value: string) => void;
}
badge는 옵션의 접근 가능한 이름에 포함된다. 그래서 스크린 리더 사용자도 화면을 보는 사용자와 같은 단서를 얻는다. 카드 안에는 상호작용 요소를 하나도 두지 않는다.
상태
| State | Border | Fill | Indicator |
|---|---|---|---|
| Unselected | 1px --ds-radiocard-border | --ds-radiocard-bg | 비어 있음 |
| Hover | 1px --ds-radiocard-border-hover | 그대로 | 비어 있음 |
| Focus-visible | 2px --ds-radiocard-border-selected + 링 | 그대로 | 비어 있음 |
| Selected | 2px --ds-radiocard-border-selected | --ds-radiocard-bg-selected | 채워짐 |
| Disabled | --ds-radiocard-disabled-border | --ds-radiocard-disabled-bg | 비어 있음, 흐리게 |
선택하면 테두리 두께, 배경, 표시가 함께 바뀐다. 색각 이상이 있으면 배경은 거의 안 보이고 테두리 색 차이도 잘 느껴지지 않는다. 그래도 구분되는 건 두께와 표시다.
동작
Senco는 은행과 크립토를 Radio card로 보여 준다. 설정을 고르는 화면이 아니라 둘을 비교하는 화면으로 만든다. 카드마다 선택을 실제로 가르는 숫자 두 개, 총비용과 도착 시간을 보여 주므로 사용자는 정산 방식을 몰라도 제대로 고를 수 있다. Design principles 참고.
콘텐츠
라벨은 같은 형식으로 쓰고, 작동 방식이 아니라 옵션 자체를 부른다. "SWIFT settlement"가 아니라 "Bank transfer".
접근성
RadioGroup과 같다. Tab은 한 번만 멈추고, 화살표 키로 이동하면서 선택한다. 배지는 옵션의 접근 가능한 이름에 포함된다. 카드 안에는 포커스 가능한 요소를 두지 않는다.
키보드
Radio group처럼 그룹 전체에서 Tab은 한 번만 멈춘다.
| Key | Action |
|---|---|
Tab | 그룹으로 들어가거나 빠져나간다 |
← → ↑ ↓ | 이동하면서 선택한다 |
Home / End | 첫 카드 / 마지막 카드 |
토큰
--ds-radiocard-bg · -border · -border-hover · -border-selected · -bg-selected · -indicator · -indicator-on · -disabled-bg · -disabled-border · -disabled-text
Radio card variants
Expanded 카드에는 구분선과 키-값 세부 행이 들어가고, 필요하면 배지(Cheapest, Fastest, Recommended)를 단다.
상태
| State | Visual treatment |
|---|---|
| Unselected | 1px 테두리, 흰 배경 |
| Hover | 더 진한 테두리, 옅은 그림자 |
| Focus-visible | 2px Primary 테두리, 포커스 링 |
| Selected | 2px Primary 테두리, 옅은 색 배경, 채워진 표시 |
| Disabled | 흐리게, not-allowed 커서 |
규칙
- 그룹당 옵션 최대 6개
- 같은 그룹에 compact와 expanded를 섞지 않는다
- 카드 안에 상호작용 요소를 두지 않는다
- 미리 선택할 때는 그 이유를 알려 주는 배지를 함께 단다
선택에 신호 셋을 쓰는 이유
테두리 두께, 배경 색, 표시 채움이 모두 함께 바뀐다. 색각 이상이 있으면 배경 색은 거의 안 보이고 테두리 색 변화도 약하다. 그래도 구분되는 건 테두리 두께와 채워진 표시다. 돈이 어디로 갈지 정하는 컨트롤에서 신호 하나로는 부족하다.
접근성
- RadioGroup과 같다. Tab은 한 번만 멈추고, 화살표 키로 이동하면서 선택한다.
- 배지("Cheapest")는 옵션의 접근 가능한 이름에 포함된다. 그래서 스크린 리더 사용자도 화면을 보는 사용자와 같은 단서를 얻는다.
- 카드 안에 상호작용 요소를 두지 않는다. 라디오 옵션 안에 포커스 가능한 컨트롤이 있으면 키보드 동작이 정의되지 않는다.
Search select
긴 목록에서 검색해 옵션 하나를 고르는 드롭다운. 열면 팝오버가 뜨고 검색 필드에 바로 포커스가 간다. cmdk로 만들었다.
- 선택된 항목에는 체크 표시가 붙는다. 다시 고르면 선택이 해제된다
- Escape를 처음 누르면 검색어를 지우고, 한 번 더 누르면 닫는다
- 네이티브 폼 제출용 숨은 입력을 둔다
구조
공통 필드 구조를 따른다. 컨트롤은 combobox 트리거이고, 팝오버 안에 검색 필드와 옵션 목록이 있다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Plain | layout | 그룹 없는 목록 |
| Grouped | layout | 나머지 옵션 위에 "Suggested" 그룹을 고정한다 |
API
interface SearchSelectProps extends FieldBase {
options: SelectOption[];
value: string | null;
onValueChange: (value: string | null) => void; // selecting the current value deselects it
searchKeys?: (option: SelectOption) => string[]; // match on more than the label — code, dial code
emptyMessage?: string; // default: "No results" plus a next step
}
Escape는 두 단계로 동작한다. 처음엔 검색어를 지우고, 다음에 닫는다. 검색어를 잘못 쳤다고 팝오버가 닫혀 버리지 않는다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Open | 팝오버가 열리고 검색 입력에 바로 포커스가 간다 |
| Filtering | 결과 개수를 polite로 알린다 |
| No results | 빈 팝오버로 두지 않는다. 상황을 알리고 다음에 할 일을 제시한다 |
동작
- 검색은 화면에 보이는 라벨에만 매칭하지 않는다. 국가를 찾을 때 "NG", "Nigeria", 국가 번호 "+234" 중 무엇을 쳐도 찾을 수 있다. 사용자는 자기가 아는 쪽을 입력하기 때문이다.
- 결과가 없을 때는 빈 팝오버 대신 상황을 알리고 다음에 할 일을 제시한다.
- 숨은 네이티브 입력이 있어서 일반 폼 제출과 검증에 그대로 참여한다.
콘텐츠
빈 상태에서는 무엇을 검색했는지 보여 주고 다음에 할 일을 제시한다.
접근성
- Combobox 패턴을 따른다.
role="combobox"에aria-expanded,aria-controls,aria-activedescendant를 단다. - 필터링하면 결과 개수를 polite로 알린다.
Esc는 지우기, 닫기 두 단계로 동작한다. 검색어를 잘못 쳤다고 팝오버가 닫혀 버리지 않는다.
키보드
| Key | Action |
|---|---|
Enter / ↓ | 연다 |
↑ ↓ | 활성 옵션을 옮긴다 |
Enter | 활성 옵션을 선택한다. 이미 선택된 값이면 선택이 해제된다 |
Esc | 처음엔 검색어를 지우고, 한 번 더 누르면 닫는다 |
토큰
공통 필드 토큰에 --ds-searchselect-item-selected-icon, --ds-searchselect-search-bg, --ds-z-popover를 더한다.
Multi select
검색해서 여러 옵션을 고르는 드롭다운. 선택한 항목은 지울 수 있는 칩으로 보여 준다.
- 하나를 골라도 팝오버는 닫히지 않는다
- 백스페이스로 마지막 칩을 지운다
maxDisplay는 "+N more"로 접기 전에 보여 줄 칩 개수다max는 고를 수 있는 최대 개수다
구조
공통 필드 구조를 따른다. 컨트롤 안에 칩과 검색 입력이 있고, 팝오버에 옵션 목록이 있다.
변형
하나.
API
interface MultiSelectProps extends FieldBase {
options: SelectOption[];
value: string[];
onValueChange: (value: string[]) => void;
maxDisplay?: number; // chips shown before "+N more"
max?: number; // selection ceiling
}
칩마다 지우기 버튼이 따로 있고, 버튼 이름은 구체적으로 붙인다("Remove Nigeria"). 백스페이스로 지우는 건 단축키일 뿐, 그것만으로 지울 수 있게 해서는 안 된다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Open | 여러 개를 고르는 동안 계속 열려 있다 |
At max | 남은 옵션은 aria-disabled가 된다. 한도에 닿았다는 사실을 말없이 넘기지 않고 알린다 |
| Overflow | maxDisplay를 넘는 칩은 "+N more"로 접는다 |
동작
여러 개를 고르는 동안 팝오버는 계속 열려 있다. 칩은 옵션 순서가 아니라 고른 순서대로 렌더링해서, 마지막에 고른 항목이 보이게 한다. max에 닿으면 남은 옵션은 aria-disabled가 되고 한도를 알린다.
콘텐츠
트리거에는 maxDisplay를 넘기 전까지 개수가 아니라 고른 항목을 그대로 보여 준다.
접근성
- 칩마다 지우기 버튼을 따로 두고, × 하나만 두지 않고 접근 가능한 이름("Remove Nigeria")을 붙인다.
- 백스페이스로 지우는 건 단축키일 뿐, 유일한 방법이어서는 안 된다 (SC 2.1.1).
- 선택하거나 지울 때마다 현재 개수와 함께 polite로 알린다.
키보드
| Key | Action |
|---|---|
↑ ↓ | 활성 옵션을 옮긴다 |
Enter | 활성 옵션을 선택하거나 해제한다 |
빈 검색에서 Backspace | 마지막 칩을 지운다. 단축키일 뿐, 유일한 방법이 아니다 |
Esc | 검색어를 지우고, 그다음 닫는다 |
토큰
공통 필드 토큰에 --ds-multiselect-chip-bg, -chip-border, -chip-text, -chip-remove-hover, -search-bg, -item-selected-icon을 더한다.
2 selected
Input OTP
일회용 비밀번호를 한 자리씩 따로 입력하는 필드.
- 입력하면 포커스가 다음 필드로 넘어간다
- 붙여 넣으면 모든 필드가 한 번에 채워진다
붙여 넣기는 동작해야 한다
WCAG 2.2 SC 3.3.8은 인증 과정에서 인지 능력 테스트를 요구하지 말라고 한다. 일회용 코드에 붙여 넣기를 막으면 사용자는 타이머가 도는 동안 여섯 자리를 외워서 다시 쳐야 한다. 어느 필드에 붙여 넣든 이벤트 하나로 모든 필드를 채운다.
구조
공통 필드 구조를 따른다. 컨트롤은 한 글자짜리 필드 length개를 --ds-space-2 간격으로 놓은 것이고, 그 아래에 카운트다운과 다시 보내기 액션이 있다.
변형
length로 나뉜다. 기본은 6.
API
interface InputOTPProps extends Omit<FieldBase, "label"> {
legend: string; // "One-time code"
length: number; // default: 6
value: string;
onValueChange: (value: string) => void;
onComplete?: (value: string) => void;
expiresAt?: Date; // renders a role="timer" countdown
onResend?: () => void; // always available
}
어느 필드에 붙여 넣든 이벤트 하나로 모든 필드를 채운다. 붙여 넣기를 막으면 사용자는 타이머가 도는 동안 코드를 외워야 하고, SC 3.3.8에 걸린다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Partially filled | 포커스는 첫 번째 빈 필드에 둔다 |
| Complete | onComplete를 호출한다. 필드는 계속 고칠 수 있다 |
| Expired | 필드를 비우고, 포커스를 다시 보내기 액션으로 옮긴다 |
동작
숫자를 치면 다음 필드로 넘어간다. 빈 필드에서 백스페이스를 누르면 앞 필드로 돌아가 지운다. 어느 필드에 붙여 넣든 이벤트 하나로 모든 필드를 채운다. 코드가 만료되면 필드를 비우고 포커스를 다시 보내기로 옮긴다.
콘텐츠
legend에는 코드를 어디로 보냈는지 적는다("Enter the code we sent to +234 ••• 4821"). 코드를 받지 못한 사용자가 어느 경로에서 문제가 생겼는지 알 수 있다.
접근성
- 그룹 전체에 접근 가능한 이름 하나("One-time code")를 붙이고, 각 필드는 자기 위치("digit 3 of 6")를 읽어 준다.
- 빈 필드에서 백스페이스를 누르면 포커스가 앞으로 돌아가 이전 숫자를 지운다.
- 만료 카운트다운은 화면에만 보이는 시계로 두지 않고,
role="timer"와 polite 안내를 쓴다. - 다시 보내기는 언제든 할 수 있다. 보이지 않는 타이머가 끝나야 열리는 식으로 만들지 않는다.
키보드
| Key | Action |
|---|---|
| 숫자 | 채우고 다음 필드로 넘어간다 |
Backspace | 현재 필드를 지운다. 이미 비어 있으면 앞 필드로 돌아가 지운다 |
← → | 필드 사이를 이동한다 |
Ctrl/Cmd+V | 어느 필드에서든 한 번 붙여 넣으면 모든 필드가 채워진다 |
토큰
공통 필드 토큰에 더해, 숫자에 --ds-text-xl을 쓴다.
File uploader
파일 업로드용 복합 컴포넌트. 주로 KYC 인증(신분증, 주소 증빙, 셀카)에 쓴다.
네 부분으로 나뉜다. FileUpload(최상위 상태 관리), FileUploadDropzone(드래그 앤 드롭 영역), FileUploadItem(진행률이 보이는 파일 행), FileUploadItemList(항목 전체 렌더링).
구조
드롭존에 아이콘, 안내 문구, 찾아보기 버튼이 있다. 그 아래에 파일 행 목록이 오고, 행마다 파일 이름, 크기, 진행률, 지우기 컨트롤이 있다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Single | selection-mode | 신분증, 셀카 |
| Multiple | selection-mode | 여러 장으로 된 주소 증빙 |
API
interface UploadFile {
id: string;
name: string;
size: number;
status: "pending" | "uploading" | "complete" | "error";
progress?: number;
error?: string;
}
interface FileUploaderProps extends Omit<FieldBase, "readOnly"> {
accept: string; // stated to the user before they pick, not after failure
maxSize: number; // bytes, stated up front
multiple?: boolean; // default: false
files: UploadFile[];
onFilesAdd: (files: File[]) => void;
onFileRemove: (id: string) => void;
onFileRetry: (id: string) => void;
onFileCancel: (id: string) => void;
timeoutMs?: number; // default: 30000 — see Tokens > Timing and threshold values
}
드롭존은 언제나 버튼 역할도 한다. 드래그는 포인터로만 할 수 있어서, 드래그로만 올릴 수 있으면 SC 2.5.7에 걸린다.
상태
| Status | Visual treatment |
|---|---|
| Pending | 흐린 아이콘, 파일 크기 |
| Uploading | Primary 아이콘, 진행 막대, 퍼센트 |
| Complete | 초록 체크, 성공 테두리 |
| Error | 빨간 경고 아이콘, 에러 테두리, 메시지 |
동작
- 드래그 앤 드롭은 덤이다. 드롭존은 언제나 파일 선택기를 여는 버튼 역할도 한다. 드래그는 포인터로만 할 수 있어서, 드래그로만 올릴 수 있으면 SC 2.5.7에 걸린다.
- 허용 형식과 크기 한도는 업로드가 실패한 다음이 아니라 파일을 고르기 전에 알려 준다.
- 업로드에 실패한 파일은 다시 시도 버튼과 함께 목록에 남겨 둔다. 지워 버리면 사용자가 서류를 처음부터 다시 찾아야 한다.
- 연결이 느려도 진행률은 실제 값을 보여 주고, 업로드는 취소할 수 있다. 제품에서 가장 오래 걸리는 작업이기 때문이다.
콘텐츠
허용 형식과 크기 한도는 파일을 고르기 전에 볼 수 있도록 드롭존에 적는다.
접근성
- 드롭존은 라벨이 붙은
<button>이고, 화면에서 숨긴<input type="file">을 감싼다. - 파일 상태가 바뀌면 polite로, 실패하면 assertive로 알린다.
- 진행률은
role="progressbar"를 쓰고 퍼센트를 화면에도 보여 준다. - 파일 행의 지우기 컨트롤에는 구체적인 이름("Remove passport.jpg")을 붙인다.
키보드
| Key | Action |
|---|---|
Tab | 드롭존으로, 그다음 파일별 액션으로 이동한다 |
Enter / Space | 파일 선택기를 연다 |
토큰
--ds-fileupload-dropzone-border · -border-active · -bg-active · --ds-green-600 · --ds-red-600
Drag and drop or browse
PDF, JPG, PNG up to 10 MB
Date picker
직접 입력하거나 달력 팝오버에서 날짜 하나를 고른다.
- 입력이 먼저다.
DD-MM-YYYY를 치는 동안 대시가 자동으로 들어간다 - 월/년 드롭다운으로 멀리 있는 날짜까지 빠르게 건너뛴다 (생년월일에 알맞다)
- 잘못 입력하면 blur 때 직전의 올바른 날짜로 되돌아간다
구조
공통 필드 구조를 따른다. inline-end에 달력 트리거가 붙고, 팝오버 안에는 월·년 선택과 월별 날짜 격자가 있다.
변형
하나. 기간 선택은 별도 컴포넌트다.
API
interface DatePickerProps extends FieldBase {
value: Date | null;
onValueChange: (value: Date | null) => void;
min?: Date;
max?: Date;
isDateDisabled?: (date: Date) => string | false; // returns the reason, not just true
}
주 컨트롤은 텍스트 입력이고, 달력은 보조 수단이다. 이유 없이 "unavailable"만 보여 주면 사용자는 더 갈 곳이 없으므로 isDateDisabled는 이유를 반환한다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Open | 달력 팝오버가 열린다. 주 컨트롤은 여전히 텍스트 입력이다 |
| Invalid typed value | blur 때 마지막으로 올바르던 날짜로 되돌리고, 되돌렸다고 알린다 |
동작
입력이 먼저다. 치는 동안 대시가 자동으로 들어간다. 날짜로 읽을 수 없는 값은 blur 때 마지막으로 올바르던 날짜로 되돌리고, 되돌렸다고 알린다. 달력은 포커스만으로는 열리지 않고, 트리거를 눌러야 열린다.
콘텐츠
비활성 날짜에 포커스가 가면 고를 수 없다는 사실만이 아니라 그 이유도 알려 준다.
접근성
- 주 컨트롤은 텍스트 입력이고, 달력은 보조 수단이다. 달력으로만 고르는 날짜 필드는 스크린 리더로 쓸 수 없고, 누구에게나 느리다.
- 팝오버는 격자 패턴을 따른다. 화살표 키는 하루씩,
PageUp/PageDown은 한 달씩 이동하고,Esc는 팝오버를 닫고 포커스를 입력으로 돌려준다. - 포커스된 날짜는 숫자만 읽지 않고 전체를 읽어 준다("Tuesday, 12 March 2026").
- 비활성 날짜는 고를 수 없다는 사실만이 아니라 이유를 알려 준다("Weekends unavailable").
키보드
| Key | Action |
|---|---|
↑ ↓ ← → | 위아래는 한 주씩, 좌우는 하루씩 이동한다 |
PageUp / PageDown | 이전 달 / 다음 달 |
Enter | 포커스된 날짜를 선택한다 |
Esc | 닫고 포커스를 입력으로 돌려준다 |
토큰
공통 필드 토큰에 --ds-white, --ds-shadow-lg, --ds-z-popover를 더하고, 선택된 날짜에 --ds-primary-light를 쓴다.
Currency input
앞에 통화 기호, 뒤에 통화 코드를 붙이고 서식을 맞추는 금액 입력. inputMode="decimal"을 써서 네이티브 스피너 없이 모바일에 맞는 키보드를 띄운다.
- 포커스하면 서식 없는 숫자(
1250.00)를, blur하면 로케일 서식(1,250.00)을 보여 준다 - 소수 자릿수는 통화에 맞춘다 (KRW = 0, USD = 2)
- 통화 기호는
Intl.NumberFormat에서 가져온다. 하드코딩한 표는 두지 않는다
구조
공통 필드 구조를 따른다. 앞에 통화 기호, 뒤에 통화 코드가 붙고, 통화를 고를 수 있는 변형에서는 뒤쪽이 Select가 된다.
변형
| Variant | Suffix | When to use |
|---|---|---|
| Static | 통화 코드를 넣은 InputGroupText | 통화가 하나로 정해진 필드 (받는 금액) |
| Selectable | Select 드롭다운 | 통화를 고를 수 있는 필드 (보내는 금액) |
크기
| Size | Height | Font | Use case |
|---|---|---|---|
default | 48px (--ds-input-height) | --ds-text-base | 일반 폼 필드 |
hero | 최소 64px, 위아래 --ds-space-2 패딩 | --ds-text-display, 굵기 700, 줄 높이 1.2 | 단일 통화 금액 입력. Amount input의 hero와 같다 |
API
interface CurrencyInputProps extends FieldBase {
value: string;
onValueChange: (value: string) => void;
currency: CurrencyOption;
currencies?: CurrencyOption[]; // presence selects the selectable variant
onCurrencyChange?: (currency: CurrencyOption) => void;
size?: "default" | "hero"; // default: "default"
locale?: string; // default: the app locale
}
blur 때 서식을 맞추고, 포커스하면 서식 없는 숫자를 보여 준다. 소수 자릿수는 컴포넌트가 정하지 않고 currency.minorUnits를 따른다. 송금 화면의 메인 금액 필드에는 이 컴포넌트 대신 Amount input을 쓴다. 환산 금액 줄과 환율 견적이 함께 붙는다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Focused | 서식 없는 숫자를 보여 준다. 편집하는 동안에는 자릿수 구분자를 뺀다 |
| Blurred | currency.minorUnits 자릿수에 맞춰 로케일 서식을 적용한다 |
Over max | 인라인 에러를 띄우고, 입력한 값은 그대로 둔다 |
동작
- blur 때 서식을 넣고, 포커스하면 뺀다. 자릿수 구분자가 들어간 채로 값을 고치게 하면 캐럿 위치 버그가 생긴다.
- 소수 자릿수는 컴포넌트가 아니라 통화가 정한다. KRW는 소수 단위가 없고, BTC는 여덟 자리다.
- 통화 코드는 항상 보여 준다. 기호만으로는 어느 통화인지 알 수 없다.
$를 쓰는 통화만 여섯 개가 넘는다.
콘텐츠
라벨에는 무슨 금액인지 적는다. 통화 코드는 항상 기호와 함께 보여 준다.
접근성
화면에 라벨을 보여 주지 않는 곳에서는 aria-label을 꼭 단다. 스크린 리더는 서식이 들어간 값을 읽으므로 "1,250.00 USD"가 숫자 나열이 아니라 금액으로 들린다.
키보드
| Key | Action |
|---|---|
Tab | 필드로, 통화를 고를 수 있으면 이어서 통화 컨트롤로 이동한다 |
| 숫자와 로케일 소수점 | 입력된다(.는 언제나 소수점으로 받는다). 나머지 문자는 입력되지 않고, 기존 값도 지우지 않는다 |
토큰
공통 필드 토큰을 쓴다.
Phone input
국가 코드 선택기가 붙은 전화번호 입력. tel 입력 옆에 국기와 국가 번호를 보여 주는 SearchSelect 드롭다운을 둔다.
- 숫자만 받는다. 붙여 넣으면 숫자가 아닌 문자는 걸러 낸다
suggestedCountriesprop을 주면 맨 위에 "Suggested" 그룹이 생긴다
구조
공통 필드 구조를 따른다. 컨트롤은 Input group이다. inline-start에 국가 선택기가 있고, tel 필드가 나머지를 채운다.
변형
하나. 추천 국가 그룹은 변형이 아니라 prop이다.
API
interface PhoneInputProps extends Omit<FieldBase, "label"> {
legend: string;
country: string; // ISO 3166-1 alpha-2
onCountryChange: (code: string) => void;
value: string; // digits only
onValueChange: (value: string) => void;
suggestedCountries?: string[];
}
붙여 넣을 때 공백, 대시, 괄호를 걸러 내므로 연락처에서 복사한 번호를 손대지 않고 쓸 수 있다.
상태
번호 필드는 공통 상태를 따르고, 국가 선택기는 Search select의 상태를 따른다.
동작
붙여 넣을 때 공백, 대시, 괄호를 걸러 낸다. 국가를 바꿔도 번호는 지우지 않는다. 국가 번호를 고쳤다고 전화번호까지 바뀐 건 아니기 때문이다.
콘텐츠
필드 전체의 이름은 legend에 적는다("Phone number"). 두 부분에 따로 보이는 라벨을 달지 않는다.
접근성
- 국가 선택기와 번호 필드는 그룹 라벨 하나에 묶인 별개의 컨트롤 두 개다.
- 선택기의 접근 가능한 이름에는 국가 번호를 넣는다("Country code, Nigeria, plus 234"). 국기만으로는 아무 정보도 전달되지 않는다.
- 붙여 넣을 때 공백, 대시, 괄호를 걸러 내므로 연락처에서 복사한 번호를 손대지 않고 쓸 수 있다.
키보드
| Key | Action |
|---|---|
Tab | 국가 선택기로, 그다음 번호 필드로 이동한다 |
| 선택기에서 타이핑 이동 | 국가 이름과 국가 번호 둘 다로 찾는다 |
토큰
공통 필드 토큰에 더해, 국기에 --ds-size-icon-sm을 쓴다.
Password input
보기/숨기기 토글이 붙은 비밀번호 입력. 눈 아이콘을 누르면 type이 password와 text 사이에서 바뀌고, 포커스는 다시 입력으로 돌아간다.
강도 표시
showStrength가 true이고 값이 있으면 아래에 강도 막대가 나타난다.
| Score | Label | Width | Color |
|---|---|---|---|
| 1 | Weak | 25% | red-500 |
| 2 | Fair | 50% | yellow-500 |
| 3 | Good | 75% | accent-500 |
| 4 | Strong | 100% | green-600 |
점수 계산: 8자 이상 +1, 숫자 포함 +1, 특수 문자 포함 +1, 앞의 조건을 모두 채우고 12자 이상이면 +1.
구조
공통 필드 구조를 따른다. inline-end에 보기 토글이 붙고, 강도 표시를 켜면 아래에 강도 막대와 라벨이 붙는다.
변형
autoComplete로 나뉜다. current-password에는 강도 막대가 없고, new-password에는 있다.
API
interface PasswordInputProps extends FieldBase {
value: string;
onValueChange: (value: string) => void;
showStrength?: boolean; // default: false
requirements?: string[]; // listed before typing, ticked off as met
autoComplete?: "current-password" | "new-password";
}
붙여 넣기는 막지 않는다. 막으면 비밀번호 관리자를 쓸 수 없다. 보기 토글을 누르면 캐럿 위치를 그대로 둔 채 포커스를 입력으로 돌려준다. 강도 안내는 500ms debounce한다.
상태
공통 상태 표가 모두 적용된다. 다르거나 추가되는 상태는 다음과 같다.
| State | Deviation |
|---|---|
| Hidden / visible | 토글해도 캐럿 위치는 그대로이고, 포커스는 입력으로 돌아간다 |
| Strength shown | showStrength가 켜져 있고 값이 있을 때만 보인다. 안내는 500ms debounce |
동작
- 보기/숨기기를 누르면 캐럿 위치를 그대로 둔 채 포커스를 입력으로 돌려준다. 비밀번호를 치다가 캐럿 위치를 잃으면 사용자는 처음부터 다시 친다.
- 붙여 넣기는 막지 않는다. 붙여 넣기를 막으면 비밀번호 관리자를 쓸 수 없고, 폼 하나가 보안을 실제로 가장 크게 떨어뜨리는 방법이 바로 이것이다.
- 조건은 입력 전에 모두 보여 주고, 채울 때마다 체크한다. 틀릴 때마다 하나씩 알려 주는 식으로 하지 않는다.
콘텐츠
조건은 입력 전에 모두 보여 주고 채울 때마다 체크한다. 틀릴 때마다 하나씩 알려 주지 않는다.
접근성
- 토글은
<button>이고,aria-pressed와 상태에 따라 바뀌는 이름("Show password" / "Hide password")을 단다. - 강도는
role="status"와aria-live="polite"를 쓰고, 라벨은 텍스트로 보여 준다. 색 막대만으로는 색각 이상이 있는 사용자에게 아무 의미가 없다. - 강도 안내는 debounce해서 글자마다 읽지 않게 한다.
키보드
| Key | Action |
|---|---|
Tab | 필드로, 그다음 보기 토글로 이동한다 |
Ctrl/Cmd+V | 언제나 허용한다 |
토큰
공통 필드 토큰에 더해, 강도 막대에 --ds-red-500, --ds-yellow-500, --ds-accent-500, --ds-green-600을 쓴다.
Masked input
입력하는 대로 서식이 맞춰지는 텍스트 입력. 마스크 문자는 0 = 숫자, A = 문자, * = 영숫자다. 나머지 문자는 모두 자동으로 들어가는 고정 구분자다.
프리셋
| Preset | Pattern | Use case |
|---|---|---|
MASKS.CARD_NUMBER | 0000 0000 0000 0000 | 신용/체크카드 |
MASKS.CARD_EXPIRY | 00/00 | 카드 만료일 (MM/YY) |
MASKS.CVV_3 | 000 | 3자리 CVV |
MASKS.CVV_4 | 0000 | 4자리 CVV (Amex) |
MASKS.DATE | 00/00/0000 | 날짜 (DD/MM/YYYY) |
만료일 마스크에서 첫 숫자로 2 이상을 치면 앞에 "0"이 자동으로 붙는다("5"를 치면 "05"). detectCardBrand(digits)는 "visa", "mastercard", "amex", "unknown" 중 하나를 반환하고, 이 값에 따라 카드 아이콘이 바뀐다.
구조
공통 필드 구조를 따른다. 필요하면 inline-end에 브랜드 마크를 붙이고, detectCardBrand 결과에 따라 바꾼다.
변형
마스크 프리셋으로 나뉜다. 카드 번호, 만료일, CVV, 날짜. 마스크가 곧 variant 축이다.
API
type Mask = string; // "0" digit, "A" letter, "*" alphanumeric, anything else a literal
declare const MASKS: {
CARD_NUMBER: Mask; CARD_EXPIRY: Mask; CVV_3: Mask; CVV_4: Mask; DATE: Mask;
};
interface MaskedInputProps extends FieldBase {
mask: Mask;
value: string; // unmasked; the component owns the separators
onValueChange: (value: string) => void;
}
declare function detectCardBrand(digits: string): "visa" | "mastercard" | "amex" | "unknown";
백스페이스는 구분자가 아니라 숫자를 지운다. 붙여 넣은 값은 마스크에 맞춰 정리한다.
상태
공통 상태를 따른다. 마스크된 값이 일부만 지워지는 일은 없다. 잘못된 문자는 받았다가 지우는 게 아니라 애초에 입력되지 않는다.
동작
- 붙여 넣은 값은 마스크에 맞춰 정리한다. 대시까지 복사한 카드 번호도 검증에 걸리지 않고 서식이 제대로 맞춰진다.
- 구분자는 자동으로 넣어 주고, 사용자에게 입력하라고 하지 않는다. 사용자는 숫자만 치고 나머지는 마스크가 처리한다.
- 백스페이스는 구분자가 아니라 숫자를 지운다. 직접 치지도 않은 공백이 지워지는 건 마스크 입력에서 가장 흔한 불만이다.
콘텐츠
힌트에는 마스크만 보여 주지 말고 형식을 말로 적는다. "16 digits, on the front of your card".
접근성
- 화면에 라벨을 보여 주지 않는 곳에서는
aria-label을 꼭 단다. - 입력 형식은 마스크로 짐작하게 두지 않고, 도움말에 적어
aria-describedby로 참조한다. - 마스크된 값은 숫자를 줄줄이 읽지 않고 묶음 단위로 끊어 읽는다.
키보드
| Key | Action |
|---|---|
| 숫자 | 다음 마스크 칸을 채운다. 구분자는 자동으로 들어간다 |
Backspace | 숫자를 지운다. 구분자는 지우지 않는다 |
Ctrl/Cmd+V | 붙여 넣은 값을 마스크에 맞춰 정리한다 |
토큰
공통 필드 토큰에 더해, 브랜드 마크에 --ds-size-icon-sm을 쓴다.
Input group
앞뒤에 텍스트나 컨트롤을 붙인 입력. 통화 코드, 단위, 국가 번호, 검색 아이콘, 끝에 붙는 버튼 같은 것들이다.
구조
┌──────┬────────────────────────┬───────┐
│ $ │ 1,250.00 │ USD ▾│
└──────┴────────────────────────┴───────┘
prefix input suffix
(text) (control)
| Part | Type | Focusable |
|---|---|---|
| Prefix | 텍스트 또는 아이콘 | No |
| Input | 컨트롤 | Yes |
| Suffix (text) | 단위, 통화 코드 | No |
| Suffix (control) | Select, 버튼 | Yes |
변형
| Variant | Dimension | Suffix |
|---|---|---|
| Static | behavior | 텍스트나 아이콘. 다른 곳에 같은 정보가 있으면 aria-hidden |
| Interactive | behavior | 자체 이름과 48px 터치 영역이 있는 컨트롤 |
API
interface InputGroupProps {
prefix?: React.ReactNode; // text or icon; aria-hidden when its content is already in the label
suffix?: React.ReactNode;
suffixInteractive?: boolean; // default: false — when true the suffix owns its own name and 48px target
children: React.ReactElement; // the control
}
포커스 링은 부분마다 따로 그리지 않고 그룹 전체에 하나만 그린다. 한 컨트롤 안에 링이 두 개 보이면 컨트롤이 두 개인 것처럼 보인다.
상태
안쪽 어느 부분이 포커스를 받든 그룹 전체에 포커스 링 하나를 그린다. 개별 부분에는 링을 따로 그리지 않는다.
| State | Rendering |
|---|---|
| Default | 그룹 전체를 두르는 테두리 하나 |
| Focus-visible | 그룹에 --ds-focus-ring 하나 |
| Error | 그룹에 --ds-red-600 테두리와 --ds-error-bg 배경, --ds-focus-ring-error |
| Disabled | 뒤쪽 컨트롤까지 포함해 그룹 전체를 비활성화한다 |
동작
- 포커스 링은 부분마다 따로 그리지 않고 그룹 전체에 하나만 그린다. 한 컨트롤 안에 링이 두 개 보이면 컨트롤이 두 개인 것처럼 보인다.
- 누를 수 없는 앞뒤 텍스트는 같은 내용이 이미 라벨이나 값에 있으면
aria-hidden으로 숨긴다. - 뒤쪽 컨트롤은 자체 접근 가능한 이름과 48px 터치 영역을 갖춘 독립된 컨트롤이다. 입력의 라벨을 물려받지 않는다.
- 그룹 높이는
--ds-input-height다. 앞뒤에 무엇을 붙여도 48px보다 높아지지 않는다.
콘텐츠
뒤에 붙는 단위는 통화 코드나 단위로 적는다. 기호만 쓰지 않는다. $를 쓰는 통화만 여섯 개가 넘는다.
접근성
<label>은 그룹 래퍼가 아니라 입력에 연결한다.- 의미가 있는 단위("USD")는
aria-describedby로 참조해서 값과 함께 읽히게 한다. - 뒤쪽이 컨트롤이면, 두 부분은 같은 그룹 라벨 아래 별개의 컨트롤로 읽힌다.
키보드
| Key | Action |
|---|---|
Tab | 입력으로, 그다음 뒤쪽 컨트롤로 이동한다. 텍스트만 있는 뒤쪽은 건너뛴다 |
토큰
공통 필드 토큰을 쓴다.
Form field
라벨, 입력, 도움말, 에러를 접근성을 갖춘 한 단위로 묶는다. 입력은 children으로 넘긴다. FormField가 입력을 직접 렌더링하지는 않는다.
FormLabel은htmlFor로 입력과 연결한다FormDescription은 라벨과 컨트롤 사이에 도움말을 보여 주고, 에러가 나도 사라지지 않는다FormMessage는 컨트롤 아래에 에러를 보여 준다. 라이브 영역이 아니고role="alert"도 없다. 사용자가 필드로 돌아오면aria-describedby를 통해 읽힌다- 필수 필드는 라벨에 별표를 단다
래퍼가 존재하는 이유
라벨 연결, aria-describedby 연결, aria-invalid, id 생성, 에러 우선순위. 모든 필드에서 빠짐없이 맞아야 하는 다섯 가지인데, 폼마다 손으로 연결하면 꼭 하나씩 빠진다. FormField를 쓰면 기본값이 곧 올바른 구현이 되고, 틀리려면 일부러 벗어나야 한다.
구성 규칙
- FormField 하나에는 입력 하나. 함께 쓰는 컨트롤 두 개는 FormField 하나 안에 Input group으로 넣는다.
- 에러가 나도 설명은 그대로 둔다.
aria-describedby는 에러를 먼저, 설명을 그다음에 참조한다. - 필수 표시는 라벨 텍스트에 넣고, 그 뜻을 알려 주는 안내를 함께 둔다. 색만으로 표시하지 않는다.
구조
래퍼가 공통 구조(라벨, 컨트롤 자리, 힌트, 에러)를 그대로 렌더링한다. 자체 시각 요소는 더하지 않는다.
변형
하나. 래퍼에 변형이 생기면 레이아웃 시스템이 하나 더 생기는 셈이다.
API
interface FormFieldProps {
children: React.ReactElement; // exactly one control; two related controls are an Input group
}
FormField는 id 생성, 라벨 연결, aria-describedby 구성, aria-invalid, 에러를 힌트보다 앞에 두는 순서를 맡는다. 컨트롤을 직접 렌더링하지는 않는다. 이걸 손으로 연결하면 다섯 개는 챙겨도 여섯 번째를 빠뜨린다.
상태
FormField에는 자체 시각 상태가 없다. 대신 상태를 어떻게 조합할지를 맡는다.
| Condition | Effect |
|---|---|
error 있음 | 컨트롤 아래에 렌더링하고, aria-invalid를 설정하고, aria-describedby는 에러, 힌트 순으로 가리킨다 |
hint만 있음 | aria-describedby가 힌트를 가리킨다 |
| 둘 다 없음 | aria-describedby를 아예 달지 않는다. 비어 있는 참조는 없느니만 못하다 |
required | 라벨 텍스트에 표시한다. 표시의 뜻을 알려 주는 안내는 폼에서 맡는다 |
동작
id가 없으면 만들고, 라벨을 연결하고, 힌트와 에러로 aria-describedby를 구성하고, aria-invalid를 설정하고, 에러를 힌트보다 앞에 둔다.
콘텐츠
라벨은 무엇을 하라는 지시문이 아니라 값을 가리키는 명사구로 쓴다. "Enter the account number"가 아니라 "Recipient's account number".
접근성
<label for>를 컨트롤의 id에 연결하고, 힌트와 에러로 aria-describedby를 구성하고, aria-invalid를 설정한다. 힌트도 에러도 없으면 aria-describedby를 아예 출력하지 않는다. 비어 있는 참조는 없느니만 못하다.
키보드
FormField 자체에는 키보드 동작이 없다. 감싼 컨트롤이 전부 맡는다.
토큰
공통 필드 토큰을 쓴다.
As it appears on your ID
Auto-generated, cannot be changed
We will send a verification link
Enter a valid email address
Use letters, numbers, and symbols
Must be at least 8 characters
You can change this later
디자인과 접근성
모든 입력에는 필드 위에 보이는 라벨이 있어야 한다. 에러 상태는 색, 아이콘, 텍스트를 함께 쓰고, 색만으로 표시하지 않는다. 관련 입력은 <fieldset>과 legend로 묶는다.
에러가 난 입력에는 모두 aria-invalid="true"를 달고, aria-describedby로 에러 메시지를 가리킨다. Switch는 role="switch"와 aria-checked를 쓴다. Radio card는 테두리 두께, 배경 색, 표시 채움이라는 서로 독립된 신호 세 가지로 선택 상태를 보여 준다.
Currency, Phone, Password, Masked input을 포함해 이 페이지의 컴포넌트는 모두 FieldBase를 확장하므로 보이는 라벨을 렌더링한다. aria-label에만 기대는 컴포넌트는 없다. Password 강도는 role="status"와 aria-live="polite"를 쓴다.
권장과 금지
| Do | Don't |
|---|---|
| Put the label above the field | Use the placeholder as the label |
| Validate on blur | Validate on every keystroke |
| Keep the user's input when validation fails | Clear the field so they retype it |
| Let OTP and password fields accept paste | Block paste "for security" |
| Offer a button beside every dropzone | Ship drag-and-drop as the only upload path |
| Use three fields for a date of birth | Make someone tap back to 1987 in a calendar |
| Size the field to its content | Make a 3-digit CVV field full width |
Set autocomplete on standard fields | Disable autofill and make users retype their address |
알려진 공백
- File uploader 말고는 오프라인이나 느린 연결에서 어떻게 동작하는지 문서로 정리한 컴포넌트가 없다.
- Password 강도 점수는 길이와 문자 종류만 보기 때문에, 뻔한 비밀번호도 실제보다 강하게 평가한다.
Input group은 문서 사이트에 프리뷰가 있었지만, 이전에는 이 스펙에 빠져 있었다.- 비동기 검증(계좌 번호를 은행에 조회해 확인하는 것)과 그 로딩 상태를 다룬 스펙이 없다.