피드백
무슨 일이 있었는지, 무엇을 불러오는 중인지, 무엇이 잘못됐는지 사용자에게 알려 주는 컴포넌트.
송금 앱에서 가장 중요한 건 피드백이다. 내 돈이 지금 어디에 있는지, 사용자는 오로지 이 컴포넌트들이 보여 주는 말로 판단한다. 애매한 성공 메시지, 타임아웃 없는 스피너, 다음에 뭘 해야 할지 알려 주지 않는 에러는 하나하나가 어떤 시각적 디테일보다 신뢰를 크게 깎는다.
컴포넌트
| Component | Purpose |
|---|---|
| Alert | 맥락 메시지를 보여 준다 (info, warning, success, destructive) |
| Note | 팁, 안내, 주의 사항을 담는 인라인 콜아웃 |
| Error message | 폼 필드의 검증 에러를 구체적으로 보여 준다 |
| Error summary | 페이지의 에러를 모두 모아, 각 필드로 가는 링크와 함께 나열한다 |
| Details | 보충 정보를 접어 두는 펼침 블록 |
| Dialog | 확인이나 집중 작업에 쓰는 모달 오버레이 |
| Alert dialog | 사용자가 분명히 결정해야 넘어가는 확인 다이얼로그 |
| Tooltip | hover나 포커스하면 뜨는 맥락 힌트 |
| Toast | 잠깐 떴다가 저절로 사라지는 알림 |
| Progress | 작업이 얼마나 끝났는지 비율로 보여 주는 표시 |
| Skeleton | 콘텐츠를 불러오는 동안 보여 주는 반짝이는 자리 표시 |
| Inline loading | 그 자리에서 로딩을 표시하는 작은 스피너 |
피드백 컴포넌트 고르기
| The message is… | Use |
|---|---|
| 필드 하나에 관한 것 | Error message |
| 여러 필드에 걸쳐 제출을 막는 것 | Error summary |
| 페이지나 섹션 전체에 관한 것으로, 계속 떠 있어야 하는 것 | Alert |
| 콘텐츠 안의 보충 안내 | Note |
| 방금 한 액션에 대한 확인으로, 잠깐 보이면 되는 것 | Toast |
| 계속하기 전에 사용자가 내려야 하는 결정 | Dialog / Alert dialog |
| 사용자가 필요하면 펼쳐 볼 세부 내용 | Details |
| 컨트롤에 붙는 힌트 | Tooltip |
어떤 컴포넌트를 쓸지 헷갈릴 때는 대부분 규칙 두 개로 정리된다. 심각할수록 오래 남긴다. 사용자가 대응해야 하는 메시지는 저절로 사라지지 않는다. 범위에 맞는 자리에 둔다. 필드 에러는 필드 아래, 페이지 에러는 페이지 맨 위에 둔다.
Alert
색, 아이콘, 텍스트로 맥락 메시지를 보여 준다. 컴포넌트 이름은 Alert이고, 하위 컴포넌트로 AlertTitle, AlertDescription, AlertAction이 있다.
| Variant | Usage |
|---|---|
default | 중립적인 맥락 정보 |
info | 정보성 안내 |
warning | 액션 전에 주의를 준다 |
success | 액션이 끝났음을 확인해 준다 |
destructive | 문제가 생겼고, 조치가 필요하다 |
- 색만으로 상태를 전하지 않는다
- 의미를 드러내는 아이콘과 텍스트 라벨을 함께 넣는다
- 관련 콘텐츠 가까이나 페이지 맨 위에 둔다
구조
┌──────────────────────────────────────────┐
│ ▌ [icon] Title │ --ds-{variant}-text
│ ▌ Description text │ --ds-fg
│ ▌ [Action] │
└──────────────────────────────────────────┘
│ bg: --ds-{variant}-bg
└── border: --ds-{variant}-border
| Part | Required | Token |
|---|---|---|
| Container | Yes | --ds-{variant}-bg, --ds-{variant}-border |
| Icon | Yes | --ds-{variant}-icon, --ds-size-icon |
| Title | No | --ds-text-base, 굵기 600 |
| Description | Yes | --ds-text-sm |
| Action | No | link variant 버튼 |
변형
| Variant | Dimension | Role | Use |
|---|---|---|---|
default | type | status | 중립 맥락 |
info | type | status | 안내 |
success | type | status | 잠깐의 확인이 아니라 계속 유지되는 상태 |
warning | type | alert | 액션 전에 주의를 준다 |
destructive | type | alert | 문제가 생겼고 조치가 필요하다 |
API
type AlertVariant = "default" | "info" | "warning" | "success" | "destructive";
interface AlertProps {
variant?: AlertVariant; // default: "default"
title?: string;
action?: React.ReactNode; // a link-variant Button
onDismiss?: () => void; // omit for anything the user must act on
children: React.ReactNode; // the description — required
}
variant에 따라 ARIA 역할이 정해진다. warning과 destructive는 role="alert", 나머지는 role="status"로 렌더링한다. Alert는 계속 떠 있다. 타이머로 사라지는 메시지라면 Toast를 쓴다.
상태
Alert는 컨트롤이 아니라서 상호작용 상태가 없다. 대신 나타나고 사라지는 생애 주기가 있다.
| State | Rendering |
|---|---|
| Present | 원인이 해결될 때까지 떠 있다 |
| Dismissible | onDismiss를 넘길 때만 닫기 컨트롤이 보인다. 진행을 막는 에러에는 넘기지 않는다 |
| With action | 액션 자리에 link variant 버튼을 둔다. 이때 Alert는 저절로 사라지지 않는다 |
동작
- Alert는 계속 떠 있다. 몰라도 되는 정보일 때만 닫을 수 있게 한다. 진행을 막는 에러에는 닫기 버튼이 없다.
- Alert는 타이머로 떴다가 사라지지 않는다. 그런 건 토스트다.
- Success alert는 계속 유지되는 상태("Your account is verified")에 쓴다. "Saved" 같은 잠깐의 확인은 토스트로 보여 준다.
콘텐츠
alert는 무슨 일이 있었는지, 다음에 무엇을 하면 되는지 둘 다 알려 준다. 상황만 알리고 끝나는 alert는 문제를 사용자에게 떠넘길 뿐이다.
| Do | Don't |
|---|---|
| "Rate expired — refresh to see the current rate" | "Rate expired" |
| "Verification failed — the ID photo was too blurry. Upload a clearer photo." | "Verification failed" |
| "This transfer is on hold while we check it. We'll email you within 2 hours." | "Transfer on hold" |
접근성
- destructive와 warning variant는
role="alert"를 쓴다. 읽던 내용을 끊고 바로 읽어 준다. - info와 success는
role="status"를 쓴다. 읽던 내용이 끝나기를 기다렸다가 읽어 준다. - 아이콘은
aria-hidden이다. variant의 의미는 텍스트로 전한다. - 사용자 액션에 반응해 나타나는 alert는 스크린 리더가 읽어 줘야 한다. 그러려면 컨테이너가 미리 DOM에 있어야 한다. Utilities 참고.
키보드
| Key | Action |
|---|---|
Tab | 액션과 닫기 컨트롤이 있으면 그리로 이동 |
| 본문 텍스트 자체는 포커스를 받지 않는다. |
토큰
--ds-info-bg / -border / -text / -icon · --ds-success-* · --ds-warning-* · --ds-error-* · --ds-radius-lg
Note
콘텐츠 안에 팁, 안내, 주의 사항을 담는 콜아웃.
- 왼쪽에 variant 색으로 3px 테두리를 둔다
- 은은한 배경 색으로 한 덩어리로 묶어 보이게 한다
- 필요하면 제목을 달아 구조를 줄 수 있다
- Notice/Alert보다 가볍다. 페이지 수준 피드백이 아니라 콘텐츠 안에 인라인으로 쓴다
| Variant | Border | Icon | Usage |
|---|---|---|---|
| info | Info blue | Info | 중립적인 참고 메모 |
| tip | Accent blue | Lightbulb | 도움이 되는 제안과 추천 |
| caution | Warning yellow | TriangleAlert | 중요한 단서나 시간이 걸린 정보 |
| error | Error red | CircleX | 실패한 작업이나 심각한 에러 |
- 제목은 선택이다. 짧은 콜아웃이면 설명만 있어도 된다
- 아이콘은 바꾸거나
icon={false}로 숨길 수 있다 - 시맨틱을 분명히 하려고
<aside role="note">로 렌더링한다
Note인가 Alert인가
Note는 작성자가 써 둔 콘텐츠다. 페이지의 일부라서 언제 열어도 그 자리에 있다. Alert는 시스템 피드백이라 상태가 바뀌어야 나타난다. Note는 바뀐 게 없으니 스크린 리더가 따로 읽어 주지 않고, Alert는 읽어 준다.
특히 tip variant가 원래 용도에서 벗어나지 않게 주의한다. 팁은 몰라도 되지만 알면 도움이 되는 내용이다. 돈을 잃지 않으려면 꼭 알아야 하는 단서는 팁이 아니라 caution이나 Alert로 보여 준다.
구조
inline-start에 variant 색 3px 테두리, 옅은 색 배경, 아이콘(선택), 제목(선택), 본문으로 이뤄진다.
변형
| Variant | Dimension | Border | Icon | Use |
|---|---|---|---|---|
info | type | --ds-info-border | Info | 중립적인 참고 메모 |
tip | type | --ds-accent-400 | Lightbulb | 몰라도 되지만 도움이 되는 제안 |
caution | type | --ds-warning-border | TriangleAlert | 독자가 꼭 알아야 할 단서 |
error | type | --ds-error-border | CircleX | 실패한 작업을 문장으로 설명 |
용도가 흐려지기 쉬운 건 tip이다. 돈을 잃지 않게 막아 주는 단서는 caution이나 Alert로 쓰고, 팁으로 쓰지 않는다.
API
interface NoteProps {
variant?: "info" | "tip" | "caution" | "error"; // default: "info"
title?: string;
icon?: React.ReactNode | false; // false hides it
children: React.ReactNode;
}
Note는 작성자가 써 둔 콘텐츠라서 바뀐 게 없으므로 스크린 리더가 따로 읽어 주지 않는다. Alert는 시스템 피드백이라 읽어 준다.
상태
작성자가 써 둔 콘텐츠라서 상태가 없다. 언제 열어도 똑같이 렌더링되고, 바뀐 게 없으니 따로 읽어 주지도 않는다.
동작
정적이다. 닫을 수도, 접을 수도 없고, 따로 읽어 주지도 않는다.
콘텐츠
Note 하나에는 내용 하나만 담는다. 문단이 세 개나 되면 Note가 아니라 제목을 단 섹션으로 만들어야 한다.
| Do | Don't |
|---|---|
| "Rates update every 30 seconds." | "Note:" as the title |
Put a fee caveat in caution | Put a fee caveat in tip |
접근성
<aside role="note">로 렌더링한다. 아이콘은 aria-hidden이다. variant의 의미는 문장이 전하므로 icon={false}로 숨겨도 문제없다.
키보드
포커스를 받지 않고 상호작용하지 않는다. 자체 키보드 동작은 없다. 안에 든 인라인 링크는 Link 동작을 따른다.
토큰
--ds-info-* · --ds-tip-* · --ds-warning-* · --ds-error-* · --ds-radius-md · --ds-space-4
Error message
폼 필드의 구체적인 검증 에러를 보여 준다.
- 입력 필드 아래에 나타난다
aria-describedby로 입력과 연결한다- 빨간색과 텍스트 설명을 함께 쓴다
- 검증에 실패하면 바로 보인다
구조
| Part | Token |
|---|---|
| 아이콘 | --ds-error-icon, 16px |
| 텍스트 | --ds-error-text, --ds-text-sm |
| 필드와의 간격 | --ds-space-2 |
필드도 함께 바뀐다. 테두리는 --ds-red-600, 배경은 --ds-error-bg, 포커스 링은 --ds-focus-ring-error가 되고 aria-invalid="true"가 붙는다.
변형
하나뿐이다. 필드 수준 에러는 이 컴포넌트, 폼 수준 에러는 Error summary, 페이지 수준 에러는 Alert로 보여 준다.
API
interface ErrorMessageProps {
id: string; // referenced by the field's aria-describedby
children: React.ReactNode;
}
라이브 영역이 아니다. 메시지는 사용자가 필드로 돌아왔을 때 읽힌다. blur할 때마다 읽어 주면 아직 폼을 채우고 있는 사람을 방해한다.
상태
| State | Rendering |
|---|---|
| Absent | 아무것도 렌더링하지 않는다. 필드의 aria-describedby는 힌트를 가리킨다 |
| Present | 컨트롤 아래에 표시하고 힌트도 남긴다. 필드 테두리 --ds-red-600, 배경 --ds-error-bg, aria-invalid="true" |
| Resolving | 필드가 유효해지는 즉시 사라진다. 사용자가 고치는 중이므로 실시간으로 반영한다 |
동작
blur하거나 제출할 때 나타나고, 키를 누를 때마다 나타나지는 않는다. 단, 이미 뜬 에러를 고치는 중이라면 제대로 고쳤는지 바로 알 수 있게 실시간으로 사라진다.
콘텐츠
에러 메시지는 무엇이 문제인지, 어떻게 고치면 되는지를 사용자가 쓰는 말로 알려 준다.
| Do | Don't |
|---|---|
| "Enter an amount between 1.00 and 5,000.00 USD" | "Invalid amount" |
| "This account number is 10 digits — you entered 9" | "Format error" |
| "You don't have enough to send this. Your balance is 180.00 USD." | "Insufficient funds" |
| "Enter the recipient's full name as it appears on their bank account" | "Name required" |
에러 코드나 스키마의 필드 이름을 그대로 보여 주지 않고, "invalid" 한 단어로 끝내지도 않는다.
접근성
필드의 aria-describedby로 참조하고 aria-invalid="true"와 함께 쓴다. 라이브 영역으로 만들지 않은 건 의도한 것이다. blur할 때마다 읽어 주면 아직 폼을 작성 중인 사람을 방해한다.
키보드
포커스를 받지 않고 상호작용하지 않는다. 자체 키보드 동작이 없다.
토큰
--ds-error-bg · --ds-error-text · --ds-error-icon · --ds-red-600 · --ds-focus-ring-error · --ds-text-sm
Error summary
제출했을 때 여러 필드가 검증에 실패하면 폼 맨 위에 에러 요약이 나타난다. 요약에는 모든 에러가 해당 필드로 가는 링크로 나열된다.
| Requirement | Detail |
|---|---|
| 위치 | 폼 맨 위, 첫 필드 위 |
| 역할 | role="alert". 읽던 내용을 끊고 바로 읽어 준다 |
| 포커스 | 제출에 실패하면 요약으로 이동한다 |
| 링크 | 각 항목은 필드의 id로 연결되고, 누르면 포커스가 그 필드로 간다 |
| 순서 | 화면에 보이는 필드 순서와 같다 |
이 패턴이 제대로 작동하는 건 포커스가 요약으로 옮겨 가기 때문이다. 스크린 리더 사용자가 한 번도 닿지 못하는 요약은 아무도 읽지 않는 목록일 뿐이다. 전체 패턴은 Validation에 있다.
구조
에러 개수를 적은 제목이 있는 테두리 영역과, 그 아래 링크를 담은 순서 있는 목록으로 이뤄진다.
변형
하나뿐이다.
API
interface ErrorSummaryProps {
errors: Array<{ fieldId: string; message: string }>; // in visual field order
heading?: (count: number) => string; // default: n => `There are ${n} problems with this form`
}
| Prop | Contract |
|---|---|
errors | 검증 로직이 만든 순서가 아니라 화면에 보이는 필드 순서를 따라야 한다 |
errors[].message | 필드의 인라인 메시지와 똑같이 쓴다. 한 문제를 두 가지로 표현하면 문제가 두 개인 줄 안다 |
errors[].fieldId | 각 항목은 이 id로 연결되고, 스크롤만 하는 게 아니라 포커스까지 옮긴다 |
"This transfer exceeds your monthly limit"처럼 특정 필드에 속하지 않는 서버 에러는 fieldId 없이 같은 목록 맨 위에 넣는다.
상태
| State | Rendering |
|---|---|
| Hidden | 에러 없음. 영역을 미리 만들어 두려고 컴포넌트는 마운트된 채로 둔다 |
| Visible | 에러가 하나 이상. 제출에 실패하면 포커스가 여기로 온다 |
| Resolving | 필드를 고치면 해당 항목이 사라지고, 제목의 개수도 갱신된다 |
닫힌 상태는 없다. 에러가 모두 사라지면 요약도 사라진다.
동작
제출에 실패하면 나타나서 포커스를 받는다. 필드를 고칠 때마다 항목이 사라지고 개수가 갱신된다. 마지막 에러가 사라질 때 함께 사라지며, 사용자가 직접 닫지는 않는다.
콘텐츠
제목에는 에러 개수를 적는다. 각 항목은 필드의 인라인 메시지를 글자 그대로 옮긴다. 한 문제를 두 가지로 표현하면 문제가 두 개인 줄 안다.
접근성
role="alert"와tabIndex={-1}을 주고, 제출에 실패하면 포커스를 받는다.- 순서 있는 목록으로 렌더링해서, 개수가 제목뿐 아니라 마크업 구조로도 전달되게 한다.
- 각 항목은 필드의
id로 가는 실제 링크다.
키보드
| Key | Action |
|---|---|
Tab | 필드 순서대로 에러 링크를 차례로 이동 |
Enter | 해당 필드로 이동하고 포커스를 옮긴다 |
토큰
--ds-error-bg · --ds-error-border · --ds-error-text · --ds-error-icon · --ds-radius-lg
접근성
- 옆에 놓아두기만 하고 알아서 읽히길 바라지 말고,
aria-describedby로 입력과 연결한다. - 에러가 있는 동안 필드에는
aria-invalid="true"를 단다. - 메시지 자체는 라이브 영역이 아니다. 사용자가 필드로 돌아왔을 때 읽힌다. blur할 때마다 읽어 주면 아직 작업 중인 사용자를 방해한다.
- 색, 아이콘, 텍스트를 함께 쓴다. 빨간색만으로는 SC 1.4.1을 통과하지 못한다.
Details
보충 정보를 접어 두는 펼침 블록.
- 수수료 내역, 추가 맥락, 선택적 콘텐츠에 쓴다
- 화살표 아이콘으로 펼침/접힘 상태를 보여 준다
- 부드럽게 열리고 닫힌다
- 키보드 접근성을 갖춘 Accordion 컴포넌트를 기반으로 만들었다
Details에 숨기면 안 되는 것
수수료, 환율, 합계는 확인 단계 전에 보여야 하고, 펼침 영역 안에 넣지 않는다. 이미 합계로 보여 준 수수료의 세부 내역은 Details에 넣어도 된다. 하지만 비용이 있다는 사실 자체를 그 안에 숨겨서는 안 된다. 화살표 뒤에 요금을 숨기는 것이야말로 Design principles가 막으려는 패턴이다.
구조
요약 행(inline-start 화살표와 라벨)과 그 아래 패널로 이뤄진다.
변형
하나뿐이다. 변형이 필요한 펼침 UI라면 Accordion을 쓴다.
API
interface DetailsProps {
summary: string;
defaultOpen?: boolean; // default: false
children: React.ReactNode;
}
이미 합계로 보여 준 비용의 세부 내역은 넣어도 된다. 비용이 있다는 사실 자체는 넣으면 안 된다.
상태
| State | Rendering |
|---|---|
| Collapsed | inline-start 화살표, aria-expanded="false" |
| Expanded | aria-expanded="true". 동작 줄이기 설정이 아니면 높이 애니메이션이 들어간다 |
| Disabled | 제공하지 않는다. 사용자가 열 수 없는 펼침 블록은 아예 렌더링하지 않는다 |
동작
자기 트리거로만 열고 닫는다. 옆의 Details가 열려도 닫히지 않는다. 아코디언 그룹이 아니라 각각 독립된 펼침 블록이다.
콘텐츠
요약에는 안에 든 내용을 이름으로 적는다("Fee breakdown"). 그래야 사용자가 열어 보기 전에 열지 말지 정할 수 있다.
접근성
- 트리거는
<button>으로 만들고aria-expanded와, 패널을 가리키는aria-controls를 단다. - 화살표는
aria-hidden이다. 상태는aria-expanded로 전한다. - 높이 애니메이션은
prefers-reduced-motion을 따른다. 설정돼 있으면 콘텐츠가 서서히 펼쳐지지 않고 바로 나타난다.
키보드
| Key | Action |
|---|---|
Tab | 요약 트리거로 이동 |
Enter / Space | 토글 |
토큰
--ds-disclosure-text · --ds-disclosure-icon · --ds-border · --ds-duration-normal
Dialog anatomy
다이얼로그 컴포넌트의 내부 구조를 영역별로 나눠 본다.
| Region | Contains | Token |
|---|---|---|
| Overlay | 페이지를 덮는 반투명 배경 | --ds-overlay, --ds-z-overlay |
| Container | 다이얼로그 표면 | --ds-white, --ds-radius-xl, --ds-shadow-lg, --ds-z-modal |
| Header | 제목, 닫기 버튼(선택) | --ds-text-lg / 600 |
| Body | 설명과 콘텐츠 | --ds-text-base |
| Footer | 취소와 확인 액션 | --ds-space-4 간격 |
모든 영역의 패딩은 --ds-space-5다. 600px 미만 모바일에서는 다이얼로그가 바텀 시트로 바뀌고, 위쪽 가장자리에만 --ds-radius-2xl을 준다.
Add new recipient
Enter the recipient details to save them for future transfers.
- 1Container
- 2Title
- 3Description
- 4Action buttons
Dialog
뒤에 있는 페이지를 조작할 수 없게 막는 모달 오버레이.
- 확인, 파괴적 액션, 집중 작업에 쓴다
- 분명한 제목과 설명을 단다
- 취소/닫기 액션을 항상 제공한다
- 열려 있는 동안 포커스를 다이얼로그 안에 가둔다
- Escape 키나 오버레이 클릭으로 닫는다
구조
오버레이, 컨테이너, 제목과 닫기(선택)가 있는 헤더, 본문, 취소·확인 버튼이 있는 푸터로 이뤄진다.
API
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
role?: "dialog" | "alertdialog"; // default: "dialog"
title: string; // required
description?: string;
confirmLabel: string; // restates the action — "Delete recipient", never "Yes"
onConfirm: () => void;
cancelLabel?: string; // default: "Cancel"
destructive?: boolean; // default: false
mobileAsSheet?: boolean; // default: true below --ds-bp-mobile
children?: React.ReactNode; // form content for the focused-task variant
}
변형
| Variant | Use | Initial focus |
|---|---|---|
| Basic | 수취인 편집, 통화 선택 같은 집중 작업 | 첫 필드, 또는 다이얼로그 컨테이너 |
| Form | 다이얼로그 안에서 여러 필드를 다루는 작업 | 첫 필드 |
| Full-screen | 모바일에서 작업이 화면을 꽉 채울 때 | 첫 필드 |
계속하기 전에 사용자가 결정해야 하는 상황에는 Alert dialog를 쓴다. Dialog의 변형이 아니라 역할 자체가 다르다.
상태
| State | Behaviour |
|---|---|
| Closed | 렌더링하지 않는다. 포커스는 트리거에 있다 |
| Open | 포커스가 안으로 들어오고, 뒤의 콘텐츠는 inert, body 스크롤은 잠긴다 |
| Submitting | 확인 버튼에 인라인 로딩을 띄우고, 다시 제출되지 않게 잠근다 |
| Closed | 포커스가 트리거로 돌아간다 |
동작
열리면 body 스크롤을 잠그고 뒤의 페이지를 inert로 만든다. Escape나 오버레이 클릭으로 닫는다. 닫히면 포커스가 트리거로 돌아간다. 다이얼로그 위에 다이얼로그를 띄우지 않는다.
콘텐츠
결정을 요구하는 다이얼로그라면 제목을 질문("Delete this recipient?")으로, 작업용이라면 명사구("Edit recipient")로 쓴다.
설명에는 어떤 일이 생기는지 적는다. 돈에 생길 일을 먼저, 데이터에 생길 일을 그다음에 쓴다.
접근성
role="dialog"에 aria-modal="true"를 주고, 제목은 aria-labelledby, 설명은 aria-describedby로 연결한다. 열려 있는 동안 포커스를 가두고, 닫히면 원래 자리로 돌려준다.
키보드
| Key | Action |
|---|---|
Tab / Shift+Tab | 다이얼로그 안에서 순환 이동 |
Esc | 닫기 |
토큰
--ds-overlay · --ds-z-overlay · --ds-z-modal · --ds-white · --ds-radius-xl · --ds-shadow-lg · --ds-duration-slow
Add new recipient
Enter the recipient details to save them for future transfers.
Alert dialog
흐름을 멈추고 결정을 받아 내는 확인 창. 역할, 포커스 동작, 써야 하는 상황이 Dialog와 다르다.
구조
Dialog와 같다. 차이는 생김새가 아니라 어느 컨트롤이 포커스를 받느냐에 있다.
API
AlertDialog는 DialogProps를 받되, role은 "alertdialog"로 고정하고 destructive 기본값은 true로 둔다. 이 구분은 스타일 옵션이 아니다. 어느 컨트롤이 처음 포커스를 받는지가 달라진다.
type AlertDialogProps = Omit<DialogProps, "role"> & {
destructive?: boolean; // default: true — initial focus goes to cancel
};
변형
| Dialog | Alert dialog | |
|---|---|---|
| 역할 | dialog | alertdialog |
| 목적 | 집중 작업 | 반드시 내려야 하는 결정 |
| Escape / 오버레이 클릭 | 닫는다 | 닫는다 (취소로 취급) |
| 기본 포커스 | 첫 필드나 다이얼로그 | 안전한 액션. 파괴적 액션에는 절대 두지 않는다 |
| 쓰는 곳 | 수취인 편집, 통화 선택 | 연락처 삭제, 송금 취소 |
Alert dialog의 기본 포커스는 안전한 액션에 둔다. 파괴적 확인 버튼에 포커스가 가 있으면, 무심코 누른 Enter 한 번에 수취인이 삭제된다.
상태
| State | Behaviour |
|---|---|
| Closed | 렌더링하지 않는다. 포커스는 트리거에 있다 |
| Open | 안전한 액션에 포커스, 뒤의 콘텐츠는 inert, body 스크롤은 잠긴다 |
| Confirming | 확인 버튼에 인라인 로딩을 띄우고 잠근다. 취소 버튼은 계속 누를 수 있다 |
| Closed by Escape | 취소로 처리한다. 확인으로 처리하는 일은 없다 |
동작
Escape와 오버레이 클릭은 모두 취소로 처리하고, 확인으로 처리하지 않는다. 확인 버튼은 한 번 누르면 잠긴다.
콘텐츠
확인 버튼 라벨에는 액션을 다시 적어서, 맥락 없이 읽어도 뜻이 통하게 한다. "Yes"가 아니라 "Delete recipient"다. 본문에는 사용자의 돈, 그다음 데이터에 어떤 일이 생기는지 순서대로 적는다.
접근성
role="alertdialog"에는aria-labelledby와aria-describedby가 둘 다 필수다. Dialog와 달리 설명을 생략할 수 없다.- 첫 포커스는 안전한 액션에 둔다.
- Escape를 누르면 취소된다.
토큰
Dialog와 같다: --ds-overlay · --ds-z-modal · --ds-radius-xl · --ds-shadow-lg
키보드
| Key | Action |
|---|---|
Tab / Shift+Tab | 다이얼로그 안에서 순환 이동 |
Esc | 취소하고 닫는다 |
Enter | 포커스된 컨트롤 실행. 기본 포커스는 취소다 |
API
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
role?: "dialog" | "alertdialog"; // default: "dialog"
title: string; // required
description: string; // required
confirmLabel: string; // restates the action — "Delete recipient", never "Yes"
onConfirm: () => void;
cancelLabel?: string; // default: "Cancel"
destructive?: boolean; // default: false — when true, initial focus goes to cancel
mobileAsSheet?: boolean; // default: true below --ds-bp-mobile
}
role="alertdialog"는 안전한 액션에 포커스를 둔다. 파괴적 확인 버튼에 포커스가 가 있으면, 무심코 누른 Enter 한 번에 수취인이 삭제된다.
콘텐츠
확인 버튼은 질문에 "네"라고 답하는 대신 액션을 다시 적는다. "Yes"가 아니라 "Delete recipient"다. 스크린 리더 사용자는 버튼만 따로 듣게 되므로, 버튼 라벨만 읽어도 뜻이 맞아야 한다.
| Title | Body | Confirm |
|---|---|---|
| "Delete this recipient?" | "Maria Santos will be removed from your contacts. Past transfers stay in your history." | "Delete recipient" |
| "Cancel this transfer?" | "The 250.00 USD will be returned to your balance within 1 hour." | "Cancel transfer" |
접근성
aria-modal="true"를 주고,aria-labelledby는 제목을,aria-describedby는 설명을 가리킨다.- 열려 있는 동안 포커스를 가두고, 닫히면 트리거로 돌려준다.
- 뒤의 콘텐츠는
inert라서 스크린 리더로 탐색할 수 없다. Esc로 닫는다. 빠져나갈 수 없는 다이얼로그는 사용자를 가두는 함정이다.- body 스크롤을 잠근다.
토큰
--ds-overlay · --ds-z-overlay · --ds-z-modal · --ds-radius-xl · --ds-shadow-lg · --ds-duration-slow
Tooltip
hover하거나 키보드로 포커스하면 나타나는 작은 힌트.
- 보충 정보에 쓴다 (예: 수수료 설명)
- 꼭 필요한 정보는 담지 않는다
- 뷰포트 밖으로 넘치지 않게 위치를 자동으로 잡는다
상호작용 요구사항
:hover와:focus둘 다에서 보여야 한다(hover에서만 보이면 안 된다)- 나타난 뒤 최소 1.5초는 유지한다
Escape키로 닫는다- 툴팁 위로 마우스를 옮겨도 사라지지 않아야 한다
- 터치 기기에서는 길게 누르면 보이고, 다른 곳을 탭하면 닫힌다
터치에서는 툴팁을 쓸 수 없다
Senco 사용자 대부분은 hover가 없는 휴대폰을 쓴다. 툴팁은 포인터 사용자에게만 덧붙는 기능이고, 툴팁에 넣은 내용은 터치 사용자도 볼 수 있는 곳에 따로 있어야 한다. 보통은 필드 아래 도움말 텍스트나 Details 블록이 그 자리다. 수수료 설명이 툴팁에만 있으면 사용자 대다수는 그걸 보지 못한다.
구조
트리거를 가리키는 화살표가 달린 작고 어두운 표면. 일반 텍스트만 담는다.
변형
하나뿐이다. 구조나 링크가 필요하면 Popover를 쓴다.
API
interface TooltipProps {
content: string; // plain text — interactive content means this should be a Popover
children: React.ReactElement; // the trigger, which supplies its own accessible name
}
aria-describedby로 연결한다. 툴팁은 설명을 덧붙일 뿐, 접근 가능한 이름을 대신하지 않는다. 터치에는 hover가 없으므로 툴팁 내용은 터치 사용자가 볼 수 있는 곳에도 있어야 한다.
상태
| State | Rendering |
|---|---|
| Hidden | 기본 |
| Shown | hover할 때 그리고 키보드로 포커스할 때. 최소 1.5초 유지 |
| Dismissed | Esc, 또는 포인터가 트리거와 툴팁을 모두 벗어날 때 |
터치 상태는 없다. 터치 환경에서는 이 컴포넌트가 아예 나타나지 않으므로, 내용을 다른 곳에도 둬야 한다.
동작
뷰포트 안에 머물도록 위치를 다시 잡는다. 자기 트리거를 가리지 않는다. 툴팁은 한 번에 하나만 보인다.
콘텐츠
마침표 없는 짧은 한 문장. 터치에는 hover가 없으니 꼭 필요한 정보는 넣지 않는다.
접근성
aria-describedby로 트리거와 연결한다. 툴팁은 이름이 아니라 설명이다.- 툴팁 안에는 상호작용 콘텐츠를 넣지 않는다. 링크나 버튼이 필요하면 팝오버를 쓴다.
- SC 1.4.13에 따라 닫을 수 있고(dismissible), 위에 hover할 수 있고(hoverable), 저절로 사라지지 않아야(persistent) 한다.
- 아이콘만 있는 트리거에는 따로
aria-label을 붙인다. 툴팁이 이름을 대신해 주지 않는다.
키보드
| Key | Action |
|---|---|
Tab | 트리거에 포커스하면 툴팁이 보인다 |
Esc | 포커스를 그대로 둔 채 닫는다 |
토큰
--ds-heading · --ds-white · --ds-radius-md · --ds-shadow-md · --ds-z-popover
Toast
잠깐 나타났다가 저절로 사라지는 알림.
- 액션이 끝났음을 알리는 데 쓴다(예: "Transfer sent")
- 에러 토스트는 사용자가 닫을 때까지 남는다
- 뷰포트 하단에 뜬다
- success, error, neutral 변형을 지원한다
구조
아이콘, 제목, 설명(선택), 액션(선택), 닫기 컨트롤로 이뤄진다.
변형
| Variant | Dimension | Role |
|---|---|---|
success | type | role="status" |
neutral | type | role="status" |
error | type | role="alert", 저절로 사라지지 않는다 |
API
interface ToastProps {
variant?: "success" | "error" | "neutral"; // default: "neutral"
title: string;
description?: string;
action?: { label: string; onAction: () => void }; // forces duration to Infinity (SC 2.2.1)
duration?: number; // default: 5000; errors default to Infinity
}
토스트는 알림일 뿐 기록이 아니다. 송금이 실패하면 토스트를 띄우는 동시에 거래 목록의 상태도 바꿔서 남긴다.
상태
| State | Rendering |
|---|---|
| Entering | --ds-duration-normal, --ds-ease-out |
| Shown | success와 neutral은 5초. 에러 토스트와 액션이 있는 토스트는 닫을 때까지 |
| Stacked | 최대 3개. 가장 오래된 것부터 접힌다 |
| Exiting | --ds-duration-normal, --ds-ease-in |
동작
| Property | Value |
|---|---|
| 자동 닫힘 (success, neutral) | 약 5초 |
| 자동 닫힘 (error) | 없음. 사용자가 직접 닫는다 |
| 위치 | 하단, 탭 바와 safe-area inset 위 |
| 쌓기 | 최대 3개. 가장 오래된 것부터 접힌다 |
| Z-index | --ds-z-toast (600), 모달보다 위 |
토스트를 모달보다 위에 둔 건 의도한 것이다. 확인 다이얼로그가 열려 있어도 송금 실패 알림은 읽을 수 있어야 한다.
토스트가 담으면 안 되는 것
토스트는 사라진다. 놓치면 곤란한 정보를 토스트에만 담아서는 안 된다. 송금이 실패하면 토스트를 띄우는 동시에 거래 목록의 상태도 바꿔서 남긴다. 토스트는 알림일 뿐 기록이 아니다.
콘텐츠
제목에는 일어난 일을 과거형으로 쓴다("Transfer sent"). 설명에는 제목에 다 담지 못한 내용만 덧붙인다.
접근성
- success와 neutral 토스트는
role="status"(polite)를 쓴다. 에러 토스트는role="alert"(assertive)를 쓴다. - 액션이 있는데 저절로 사라지는 토스트는 SC 2.2.1을 통과하지 못한다. 액션이 있으면 토스트는 남아 있어야 한다.
- 닫기 컨트롤은 접근 가능한 이름과 48px 타깃을 갖춘 실제 버튼이다.
- 토스트 컨테이너는 항상 마운트해 두고, 안의 자식만 바꾼다.
키보드
| Key | Action |
|---|---|
Tab | 액션과 닫기 컨트롤로 이동 |
Esc | 포커스된 토스트를 닫는다 |
토큰
--ds-success-* · --ds-error-* · --ds-white · --ds-shadow-lg · --ds-z-toast · --ds-radius-lg
Progress
작업이 얼마나 끝났는지 비율로 보여 주는 막대.
- 업로드, 송금 진행, 로딩 상태에 쓴다
- 비율이나 단계를 알려 주는 텍스트 라벨을 함께 둔다
- 확정형(비율)과 불확정형(로딩) 모드를 지원한다
- 색으로 상태를 나타낸다. 진행 중은 Primary, 완료는 초록
가능한 한 확정형으로
불확정 막대가 알려 주는 건 앱이 아직 멈추지 않았다는 사실뿐이다. 서류 업로드나 여러 단계 인증처럼 실제 비율을 알 수 있으면 비율을 보여 준다. 비율을 알 수 없으면 애니메이션만 돌리기보다 단계 라벨("Checking recipient details")을 보여 주는 게 낫다. 뭔가 진행 중이라는 것만이 아니라, 무엇이 진행 중인지 알려 주기 때문이다.
구조
트랙, 채움, 그리고 옆에 붙는 텍스트 비율이나 단계 라벨로 이뤄진다. 텍스트는 필수다. 막대만 있으면 색각 이상이나 저시력 사용자는 읽을 수 없다.
변형
| Variant | Dimension | Use |
|---|---|---|
| Linear determinate | type | 업로드, 인증처럼 실제 비율을 알 수 있을 때 |
| Linear indeterminate | type | 비율을 알 수 없을 때. 단계 라벨을 함께 둔다 |
| Circular | type | 인라인, 좁은 맥락 |
API
interface ProgressProps {
value?: number; // 0–100; omit for indeterminate
label: string; // "Uploading passport.jpg", not "Loading"
showPercentage?: boolean; // default: true when value is given
status?: "active" | "complete" | "error"; // default: derived
}
스크린 리더 안내는 10%마다와 1초마다 중 더 드문 쪽에 맞춰 throttle한다.
상태
| State | Rendering |
|---|---|
| Indeterminate | value 생략, aria-valuenow 없음. 막대만 두지 않고 단계 라벨을 함께 둔다 |
| Determinate | value%만큼 채우고, 막대 옆에 비율을 텍스트로 표시 |
| Complete | 채움 --ds-green-600, 체크 아이콘 |
| Error | 채움 --ds-red-600, 옆에 다시 시도 액션 |
동작
막대는 뒤로 가지 않는다. 전체 양이 다시 계산되면 채움을 처음부터 다시 시작하지 않고 비율만 다시 맞춘다. 100%에 닿았다고 성공한 건 아니다. 성공 여부는 status로 알린다.
콘텐츠
막대 옆에는 "Please wait"가 아니라 무엇이 진행 중인지를 적는다.
접근성
- 확정형은
role="progressbar"에aria-valuenow,aria-valuemin,aria-valuemax를 단다. 불확정형은aria-valuenow를 뺀다. - 비율은 ARIA 값으로만 두지 않고 화면에 텍스트로도 보여 준다. 색각 이상이나 저시력 사용자는 막대가 얼마나 찼는지 읽기 어렵다.
- 진행 상황은 polite로 읽어 주고 throttle한다. 1%마다 읽어 주면 도저히 쓸 수 없다.
키보드
포커스를 받지 않고 상호작용하지 않는다. 자체 키보드 동작은 없다. 옆에 붙는 다시 시도 액션은 Button이다.
토큰
--ds-primary · --ds-green-600 · --ds-red-600 · --ds-surface · --ds-radius-full
Skeleton
불러올 콘텐츠의 모양을 미리 흉내 내는 반짝이는 자리 표시.
- 데이터를 가져오는 동안 쓴다
- 최종 콘텐츠와 레이아웃을 맞춰 레이아웃 밀림을 막는다
- 구조가 있는 콘텐츠 영역에서는 스피너 대신 쓴다
- 원형(아바타), 사각형(텍스트), 카드 모양이 있다
스피너보다 스켈레톤인 이유
스켈레톤은 곧 나올 콘텐츠의 모양을 미리 보여 줘서 기다림을 짧게 느끼게 하고, 스피너가 콘텐츠로 바뀔 때 생기는 레이아웃 밀림도 막는다. Design principles의 "빈 화면은 절대 없다" 규칙과도 맞는다.
스켈레톤은 최종 레이아웃과 최대한 비슷해야 한다. 높이가 다르면 막으려던 콘텐츠 밀림이 그대로 생긴다. 돈을 보내는 플로우에서 누르려던 버튼이 움직이는 것만큼 나쁜 일은 없다.
구조
대신할 콘텐츠와 같은 크기로 --ds-surface를 채운 도형.
변형
| Variant | Dimension | Matches |
|---|---|---|
text | layout | 본문 줄 높이의 한 줄 이상 |
circle | layout | 아바타나 국기 |
rect | layout | 이미지나 차트 영역 |
card | layout | 패딩까지 포함한 카드 전체 |
API
interface SkeletonProps {
shape?: "text" | "circle" | "rect" | "card"; // default: "text"
lines?: number; // shape="text" only
width?: string;
height?: string;
}
스켈레톤 높이는 최종 레이아웃과 같아야 한다. 높이가 다르면 막으려던 콘텐츠 밀림이 그대로 생긴다.
상태
| State | Rendering |
|---|---|
| Shimmering | 기본. 영역에 aria-busy="true" |
| Static | prefers-reduced-motion에서는 반짝임만 멈추고 자리 표시는 남는다 |
에러 상태는 따로 없다. 불러오기에 실패하면 스켈레톤을 남겨 두지 않고 에러 상태로 바꾼다.
동작
콘텐츠, 빈 상태, 에러 상태 중 하나로 반드시 바뀌고, 스켈레톤인 채로 남지 않는다. 셋 중 하나가 나타날 때까지 영역에 aria-busy를 둔다.
콘텐츠
없다. 텍스트가 들어가면 스켈레톤이 아니다.
접근성
- 스켈레톤 영역에는
aria-busy="true"를, 안의 콘텐츠에는aria-hidden을 준다. - polite 라이브 영역에서 "Loading transactions"를 한 번만 읽어 준다. 반짝일 때마다 읽지 않는다.
prefers-reduced-motion에서는 반짝임 애니메이션만 멈추고 자리 표시는 남긴다.
키보드
포커스를 받지 않고 상호작용하지 않는다. 자체 키보드 동작은 없다.
토큰
--ds-surface · --ds-radius-md · --ds-duration-slower
Inline loading
콘텐츠나 컨트롤 안에서 그 자리의 로딩을 표시하는 작은 스피너.
- 쓰는 곳: 환율 불러오기, 수취인 정보 확인, 잔액 조회
- 버튼 안, 텍스트 라벨 옆, 테이블 셀 안에 들어간다
- 상태: loading(스피너), success(체크 표시로 전환), error(다시 시도)
- 스피너 크기는 맥락에 맞춘다(버튼 안 14px, 텍스트 옆 16px)
- 접근성:
aria-live="polite"로 상태 변화를 알린다 - 페이지의 다른 요소는 계속 조작할 수 있다
모든 스피너에는 타임아웃이 필요하다
실패 경로가 없는 인라인 스피너는 연결이 끊기면 끝없이 돈다. 그리고 Senco 사용자에게 연결 끊김은 일상이다. 그래서 모든 로딩 상태에는 타임아웃을 두고, 시간이 지나면 다시 시도 액션이 있는 에러 상태로 바꾼다. 끝없이 도는 스피너는 이 제품이 사용자의 돈이 어떻게 되고 있는지 거짓으로 알리게 되는 가장 흔한 경우다.
구조
맥락에 맞는 크기의 스피너, 그다음 라벨 순이다. 버튼 안에서는 스피너가 앞쪽 아이콘 자리에 들어가고 라벨은 그대로 둔다.
변형
| Variant | Dimension | Size |
|---|---|---|
| In button | layout | 14px, 앞쪽 아이콘 자리 |
| Beside text | layout | 16px, 라벨과 같은 줄 |
| In cell | layout | 16px, 값 자리 |
API
interface InlineLoadingProps {
state: "loading" | "success" | "error";
label: string; // announced politely
size?: 14 | 16; // default: 16; 14 inside a button
onRetry?: () => void; // required when state can reach "error"
timeoutMs?: number; // default: 10000 — see Tokens > Timing and threshold values
}
스피너는 반드시 끝나야 한다. 타임아웃이 없으면 연결이 끊겼을 때 끝없이 도는데, 이 제품 사용자에게 연결 끊김은 흔한 일이다.
상태
| State | Rendering |
|---|---|
| Loading | 스피너와 라벨. polite로 한 번 읽어 준다 |
| Success | 체크 아이콘. 스피너를 남겨 두지 않는다 |
| Error | 경고 아이콘과 다시 시도 액션 |
| Timed out | timeoutMs가 지나면 에러 상태가 된다. 그 밖의 결과는 없다 |
동작
로딩 상태에 들어갈 때 한 번만 읽어 주고, 반복하지 않는다. timeoutMs 안에 성공이나 에러 둘 중 하나로 끝난다. 그 사이 페이지의 다른 곳은 계속 조작할 수 있다.
콘텐츠
라벨에는 "Loading"이 아니라 지금 무엇을 하는지 적는다("Checking recipient details").
접근성
- 스피너는
aria-hidden이다. 상태는 polite 라이브 영역으로 알린다. - 스피너가 들어간 컨트롤은
disabled대신aria-disabled를 쓴다. 그래야 포커스를 받고, 상태 설명도 연결할 수 있다. - 동작 줄이기 설정에서는 스피너가 회전을 멈추고 정지된 아이콘으로 남는다. 상태는 라벨로 전하므로 라벨이 필수다.
키보드
로딩 표시 자체는 포커스를 받지 않는다. 다시 시도 액션은 Button이다.
토큰
--ds-primary · --ds-green-600 · --ds-red-600 · --ds-size-icon-sm
디자인과 접근성
- Alert는 색, 아이콘, 텍스트를 함께 써서 상태를 전한다(색만으로는 안 된다)
- 에러 요약은 페이지 맨 위에 나타나고, 각 에러로 가는 링크를 담는다
- 에러 메시지는
aria-describedby로 입력과 연결한다 - Details 컴포넌트는 네이티브 펼침 패턴을 쓴다
- 동적으로 바뀌는 피드백은
aria-live영역으로 스크린 리더에 알린다 - 폼 제출에 실패하면 포커스가 에러 요약으로 간다
- 다이얼로그는 포커스를 가두고, 닫히면 돌려준다
여기 모든 컴포넌트에 적용되는 규칙
- 얼마나 오래 보일지는 심각도로 정한다. 사용자가 대응해야 하는 메시지는 저절로 사라지지 않는다.
- 어디에 둘지는 범위로 정한다. 필드 에러는 필드 아래, 페이지 에러는 맨 위에 둔다.
- 모든 메시지는 다음에 할 일을 알려 준다. 문제만 알리는 건 메시지의 절반일 뿐이다.
- 중요한 정보를 잠깐 떴다 사라지는 곳에만 두지 않는다. 토스트나 툴팁만으로 전달하지 않는다.
- 모든 로딩 상태에는 실패 상태가 있다. 타임아웃 없는 스피너는 없다.
- 색, 아이콘, 텍스트는 항상 함께 쓴다.
권장과 금지
| Do | Don't |
|---|---|
| "Rate expired — refresh to see the current rate" | "Rate expired" |
| Let an error toast persist until dismissed | Auto-dismiss a failure after 5 seconds |
| Focus the error summary on failed submit | Render a summary nobody lands on |
| Focus the safe action in an alert dialog | Focus "Delete" by default |
| Show the fee, hide only its breakdown | Hide the existence of a fee behind Details |
| Give every spinner a timeout and a retry | Ship a spinner with no failure path |
알려진 공백
- "Alert dialog"와 "Error summary"는 컴포넌트 표에는 있지만, 독립된 스펙 없이 하위 섹션으로만 문서화돼 있다.
- 문서 사이트의 라이브 영역 프리뷰 키는
Notice인데 이 페이지의 제목은Alert라서, 그 섹션은 시각 자료 없이 렌더링된다. - 컴포넌트별 스켈레톤 모양이 정해지지 않았다.
- 토스트 쌓기 동작은 문서에만 있고 구현되지 않았다.