Skip to main content
ENKO

피드백

무슨 일이 있었는지, 무엇을 불러오는 중인지, 무엇이 잘못됐는지 사용자에게 알려 주는 컴포넌트.

송금 앱에서 가장 중요한 건 피드백이다. 내 돈이 지금 어디에 있는지, 사용자는 오로지 이 컴포넌트들이 보여 주는 말로 판단한다. 애매한 성공 메시지, 타임아웃 없는 스피너, 다음에 뭘 해야 할지 알려 주지 않는 에러는 하나하나가 어떤 시각적 디테일보다 신뢰를 크게 깎는다.

컴포넌트

ComponentPurpose
Alert맥락 메시지를 보여 준다 (info, warning, success, destructive)
Note팁, 안내, 주의 사항을 담는 인라인 콜아웃
Error message폼 필드의 검증 에러를 구체적으로 보여 준다
Error summary페이지의 에러를 모두 모아, 각 필드로 가는 링크와 함께 나열한다
Details보충 정보를 접어 두는 펼침 블록
Dialog확인이나 집중 작업에 쓰는 모달 오버레이
Alert dialog사용자가 분명히 결정해야 넘어가는 확인 다이얼로그
Tooltiphover나 포커스하면 뜨는 맥락 힌트
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이 있다.

VariantUsage
default중립적인 맥락 정보
info정보성 안내
warning액션 전에 주의를 준다
success액션이 끝났음을 확인해 준다
destructive문제가 생겼고, 조치가 필요하다
  • 색만으로 상태를 전하지 않는다
  • 의미를 드러내는 아이콘과 텍스트 라벨을 함께 넣는다
  • 관련 콘텐츠 가까이나 페이지 맨 위에 둔다

구조

┌──────────────────────────────────────────┐
│ ▌ [icon]  Title                          │  --ds-{variant}-text
│ ▌         Description text               │  --ds-fg
│ ▌         [Action]                       │
└──────────────────────────────────────────┘
  │  bg: --ds-{variant}-bg
  └── border: --ds-{variant}-border
PartRequiredToken
ContainerYes--ds-{variant}-bg, --ds-{variant}-border
IconYes--ds-{variant}-icon, --ds-size-icon
TitleNo--ds-text-base, 굵기 600
DescriptionYes--ds-text-sm
ActionNolink variant 버튼

변형

VariantDimensionRoleUse
defaulttypestatus중립 맥락
infotypestatus안내
successtypestatus잠깐의 확인이 아니라 계속 유지되는 상태
warningtypealert액션 전에 주의를 준다
destructivetypealert문제가 생겼고 조치가 필요하다

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는 컨트롤이 아니라서 상호작용 상태가 없다. 대신 나타나고 사라지는 생애 주기가 있다.

StateRendering
Present원인이 해결될 때까지 떠 있다
DismissibleonDismiss를 넘길 때만 닫기 컨트롤이 보인다. 진행을 막는 에러에는 넘기지 않는다
With action액션 자리에 link variant 버튼을 둔다. 이때 Alert는 저절로 사라지지 않는다

동작

  • Alert는 계속 떠 있다. 몰라도 되는 정보일 때만 닫을 수 있게 한다. 진행을 막는 에러에는 닫기 버튼이 없다.
  • Alert는 타이머로 떴다가 사라지지 않는다. 그런 건 토스트다.
  • Success alert는 계속 유지되는 상태("Your account is verified")에 쓴다. "Saved" 같은 잠깐의 확인은 토스트로 보여 준다.

콘텐츠

alert는 무슨 일이 있었는지, 다음에 무엇을 하면 되는지 둘 다 알려 준다. 상황만 알리고 끝나는 alert는 문제를 사용자에게 떠넘길 뿐이다.

DoDon'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 참고.

키보드

KeyAction
Tab액션과 닫기 컨트롤이 있으면 그리로 이동
본문 텍스트 자체는 포커스를 받지 않는다.

토큰

--ds-info-bg / -border / -text / -icon · --ds-success-* · --ds-warning-* · --ds-error-* · --ds-radius-lg

Note

콘텐츠 안에 팁, 안내, 주의 사항을 담는 콜아웃.

  • 왼쪽에 variant 색으로 3px 테두리를 둔다
  • 은은한 배경 색으로 한 덩어리로 묶어 보이게 한다
  • 필요하면 제목을 달아 구조를 줄 수 있다
  • Notice/Alert보다 가볍다. 페이지 수준 피드백이 아니라 콘텐츠 안에 인라인으로 쓴다
VariantBorderIconUsage
infoInfo blueInfo중립적인 참고 메모
tipAccent blueLightbulb도움이 되는 제안과 추천
cautionWarning yellowTriangleAlert중요한 단서나 시간이 걸린 정보
errorError redCircleX실패한 작업이나 심각한 에러
  • 제목은 선택이다. 짧은 콜아웃이면 설명만 있어도 된다
  • 아이콘은 바꾸거나 icon={false}로 숨길 수 있다
  • 시맨틱을 분명히 하려고 <aside role="note">로 렌더링한다

Note인가 Alert인가

Note는 작성자가 써 둔 콘텐츠다. 페이지의 일부라서 언제 열어도 그 자리에 있다. Alert는 시스템 피드백이라 상태가 바뀌어야 나타난다. Note는 바뀐 게 없으니 스크린 리더가 따로 읽어 주지 않고, Alert는 읽어 준다.

특히 tip variant가 원래 용도에서 벗어나지 않게 주의한다. 팁은 몰라도 되지만 알면 도움이 되는 내용이다. 돈을 잃지 않으려면 꼭 알아야 하는 단서는 팁이 아니라 caution이나 Alert로 보여 준다.

구조

inline-start에 variant 색 3px 테두리, 옅은 색 배경, 아이콘(선택), 제목(선택), 본문으로 이뤄진다.

변형

VariantDimensionBorderIconUse
infotype--ds-info-borderInfo중립적인 참고 메모
tiptype--ds-accent-400Lightbulb몰라도 되지만 도움이 되는 제안
cautiontype--ds-warning-borderTriangleAlert독자가 꼭 알아야 할 단서
errortype--ds-error-borderCircleX실패한 작업을 문장으로 설명

용도가 흐려지기 쉬운 건 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가 아니라 제목을 단 섹션으로 만들어야 한다.

DoDon't
"Rates update every 30 seconds.""Note:" as the title
Put a fee caveat in cautionPut 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

Note

Error message

폼 필드의 구체적인 검증 에러를 보여 준다.

  • 입력 필드 아래에 나타난다
  • aria-describedby로 입력과 연결한다
  • 빨간색과 텍스트 설명을 함께 쓴다
  • 검증에 실패하면 바로 보인다

구조

PartToken
아이콘--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할 때마다 읽어 주면 아직 폼을 채우고 있는 사람을 방해한다.

상태

StateRendering
Absent아무것도 렌더링하지 않는다. 필드의 aria-describedby는 힌트를 가리킨다
Present컨트롤 아래에 표시하고 힌트도 남긴다. 필드 테두리 --ds-red-600, 배경 --ds-error-bg, aria-invalid="true"
Resolving필드가 유효해지는 즉시 사라진다. 사용자가 고치는 중이므로 실시간으로 반영한다

동작

blur하거나 제출할 때 나타나고, 키를 누를 때마다 나타나지는 않는다. 단, 이미 뜬 에러를 고치는 중이라면 제대로 고쳤는지 바로 알 수 있게 실시간으로 사라진다.

콘텐츠

에러 메시지는 무엇이 문제인지, 어떻게 고치면 되는지를 사용자가 쓰는 말로 알려 준다.

DoDon'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 message
Enter a valid email address

Error summary

제출했을 때 여러 필드가 검증에 실패하면 폼 맨 위에 에러 요약이 나타난다. 요약에는 모든 에러가 해당 필드로 가는 링크로 나열된다.

RequirementDetail
위치폼 맨 위, 첫 필드 위
역할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`
}
PropContract
errors검증 로직이 만든 순서가 아니라 화면에 보이는 필드 순서를 따라야 한다
errors[].message필드의 인라인 메시지와 똑같이 쓴다. 한 문제를 두 가지로 표현하면 문제가 두 개인 줄 안다
errors[].fieldId각 항목은 이 id로 연결되고, 스크롤만 하는 게 아니라 포커스까지 옮긴다

"This transfer exceeds your monthly limit"처럼 특정 필드에 속하지 않는 서버 에러는 fieldId 없이 같은 목록 맨 위에 넣는다.

상태

StateRendering
Hidden에러 없음. 영역을 미리 만들어 두려고 컴포넌트는 마운트된 채로 둔다
Visible에러가 하나 이상. 제출에 실패하면 포커스가 여기로 온다
Resolving필드를 고치면 해당 항목이 사라지고, 제목의 개수도 갱신된다

닫힌 상태는 없다. 에러가 모두 사라지면 요약도 사라진다.

동작

제출에 실패하면 나타나서 포커스를 받는다. 필드를 고칠 때마다 항목이 사라지고 개수가 갱신된다. 마지막 에러가 사라질 때 함께 사라지며, 사용자가 직접 닫지는 않는다.

콘텐츠

제목에는 에러 개수를 적는다. 각 항목은 필드의 인라인 메시지를 글자 그대로 옮긴다. 한 문제를 두 가지로 표현하면 문제가 두 개인 줄 안다.

접근성

  • role="alert"와 tabIndex={-1}을 주고, 제출에 실패하면 포커스를 받는다.
  • 순서 있는 목록으로 렌더링해서, 개수가 제목뿐 아니라 마크업 구조로도 전달되게 한다.
  • 각 항목은 필드의 id로 가는 실제 링크다.

키보드

KeyAction
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;
}

이미 합계로 보여 준 비용의 세부 내역은 넣어도 된다. 비용이 있다는 사실 자체는 넣으면 안 된다.

상태

StateRendering
Collapsedinline-start 화살표, aria-expanded="false"
Expandedaria-expanded="true". 동작 줄이기 설정이 아니면 높이 애니메이션이 들어간다
Disabled제공하지 않는다. 사용자가 열 수 없는 펼침 블록은 아예 렌더링하지 않는다

동작

자기 트리거로만 열고 닫는다. 옆의 Details가 열려도 닫히지 않는다. 아코디언 그룹이 아니라 각각 독립된 펼침 블록이다.

콘텐츠

요약에는 안에 든 내용을 이름으로 적는다("Fee breakdown"). 그래야 사용자가 열어 보기 전에 열지 말지 정할 수 있다.

접근성

  • 트리거는 <button>으로 만들고 aria-expanded와, 패널을 가리키는 aria-controls를 단다.
  • 화살표는 aria-hidden이다. 상태는 aria-expanded로 전한다.
  • 높이 애니메이션은 prefers-reduced-motion을 따른다. 설정돼 있으면 콘텐츠가 서서히 펼쳐지지 않고 바로 나타난다.

키보드

KeyAction
Tab요약 트리거로 이동
Enter / Space토글

토큰

--ds-disclosure-text · --ds-disclosure-icon · --ds-border · --ds-duration-normal

Accordion

Dialog anatomy

다이얼로그 컴포넌트의 내부 구조를 영역별로 나눠 본다.

RegionContainsToken
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을 준다.

Dialog anatomy
1

Add new recipient

2

Enter the recipient details to save them for future transfers.

3
4
  1. 1Container
  2. 2Title
  3. 3Description
  4. 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
}

변형

VariantUseInitial focus
Basic수취인 편집, 통화 선택 같은 집중 작업첫 필드, 또는 다이얼로그 컨테이너
Form다이얼로그 안에서 여러 필드를 다루는 작업첫 필드
Full-screen모바일에서 작업이 화면을 꽉 채울 때첫 필드

계속하기 전에 사용자가 결정해야 하는 상황에는 Alert dialog를 쓴다. Dialog의 변형이 아니라 역할 자체가 다르다.

상태

StateBehaviour
Closed렌더링하지 않는다. 포커스는 트리거에 있다
Open포커스가 안으로 들어오고, 뒤의 콘텐츠는 inert, body 스크롤은 잠긴다
Submitting확인 버튼에 인라인 로딩을 띄우고, 다시 제출되지 않게 잠근다
Closed포커스가 트리거로 돌아간다

동작

열리면 body 스크롤을 잠그고 뒤의 페이지를 inert로 만든다. Escape나 오버레이 클릭으로 닫는다. 닫히면 포커스가 트리거로 돌아간다. 다이얼로그 위에 다이얼로그를 띄우지 않는다.

콘텐츠

결정을 요구하는 다이얼로그라면 제목을 질문("Delete this recipient?")으로, 작업용이라면 명사구("Edit recipient")로 쓴다.

설명에는 어떤 일이 생기는지 적는다. 돈에 생길 일을 먼저, 데이터에 생길 일을 그다음에 쓴다.

접근성

role="dialog"에 aria-modal="true"를 주고, 제목은 aria-labelledby, 설명은 aria-describedby로 연결한다. 열려 있는 동안 포커스를 가두고, 닫히면 원래 자리로 돌려준다.

키보드

KeyAction
Tab / Shift+Tab다이얼로그 안에서 순환 이동
Esc닫기

토큰

--ds-overlay · --ds-z-overlay · --ds-z-modal · --ds-white · --ds-radius-xl · --ds-shadow-lg · --ds-duration-slow

Dialog

Add new recipient

Enter the recipient details to save them for future transfers.

Recipient details are encrypted end-to-end.

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
};

변형

DialogAlert dialog
역할dialogalertdialog
목적집중 작업반드시 내려야 하는 결정
Escape / 오버레이 클릭닫는다닫는다 (취소로 취급)
기본 포커스첫 필드나 다이얼로그안전한 액션. 파괴적 액션에는 절대 두지 않는다
쓰는 곳수취인 편집, 통화 선택연락처 삭제, 송금 취소

Alert dialog의 기본 포커스는 안전한 액션에 둔다. 파괴적 확인 버튼에 포커스가 가 있으면, 무심코 누른 Enter 한 번에 수취인이 삭제된다.

상태

StateBehaviour
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

키보드

KeyAction
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"다. 스크린 리더 사용자는 버튼만 따로 듣게 되므로, 버튼 라벨만 읽어도 뜻이 맞아야 한다.

TitleBodyConfirm
"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가 없으므로 툴팁 내용은 터치 사용자가 볼 수 있는 곳에도 있어야 한다.

상태

StateRendering
Hidden기본
Shownhover할 때 그리고 키보드로 포커스할 때. 최소 1.5초 유지
DismissedEsc, 또는 포인터가 트리거와 툴팁을 모두 벗어날 때

터치 상태는 없다. 터치 환경에서는 이 컴포넌트가 아예 나타나지 않으므로, 내용을 다른 곳에도 둬야 한다.

동작

뷰포트 안에 머물도록 위치를 다시 잡는다. 자기 트리거를 가리지 않는다. 툴팁은 한 번에 하나만 보인다.

콘텐츠

마침표 없는 짧은 한 문장. 터치에는 hover가 없으니 꼭 필요한 정보는 넣지 않는다.

접근성

  • aria-describedby로 트리거와 연결한다. 툴팁은 이름이 아니라 설명이다.
  • 툴팁 안에는 상호작용 콘텐츠를 넣지 않는다. 링크나 버튼이 필요하면 팝오버를 쓴다.
  • SC 1.4.13에 따라 닫을 수 있고(dismissible), 위에 hover할 수 있고(hoverable), 저절로 사라지지 않아야(persistent) 한다.
  • 아이콘만 있는 트리거에는 따로 aria-label을 붙인다. 툴팁이 이름을 대신해 주지 않는다.

키보드

KeyAction
Tab트리거에 포커스하면 툴팁이 보인다
Esc포커스를 그대로 둔 채 닫는다

토큰

--ds-heading · --ds-white · --ds-radius-md · --ds-shadow-md · --ds-z-popover

Tooltip

Toast

잠깐 나타났다가 저절로 사라지는 알림.

  • 액션이 끝났음을 알리는 데 쓴다(예: "Transfer sent")
  • 에러 토스트는 사용자가 닫을 때까지 남는다
  • 뷰포트 하단에 뜬다
  • success, error, neutral 변형을 지원한다

구조

아이콘, 제목, 설명(선택), 액션(선택), 닫기 컨트롤로 이뤄진다.

변형

VariantDimensionRole
successtyperole="status"
neutraltyperole="status"
errortyperole="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
}

토스트는 알림일 뿐 기록이 아니다. 송금이 실패하면 토스트를 띄우는 동시에 거래 목록의 상태도 바꿔서 남긴다.

상태

StateRendering
Entering--ds-duration-normal, --ds-ease-out
Shownsuccess와 neutral은 5초. 에러 토스트와 액션이 있는 토스트는 닫을 때까지
Stacked최대 3개. 가장 오래된 것부터 접힌다
Exiting--ds-duration-normal, --ds-ease-in

동작

PropertyValue
자동 닫힘 (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 타깃을 갖춘 실제 버튼이다.
  • 토스트 컨테이너는 항상 마운트해 두고, 안의 자식만 바꾼다.

키보드

KeyAction
Tab액션과 닫기 컨트롤로 이동
Esc포커스된 토스트를 닫는다

토큰

--ds-success-* · --ds-error-* · --ds-white · --ds-shadow-lg · --ds-z-toast · --ds-radius-lg

Toast

Progress

작업이 얼마나 끝났는지 비율로 보여 주는 막대.

  • 업로드, 송금 진행, 로딩 상태에 쓴다
  • 비율이나 단계를 알려 주는 텍스트 라벨을 함께 둔다
  • 확정형(비율)과 불확정형(로딩) 모드를 지원한다
  • 색으로 상태를 나타낸다. 진행 중은 Primary, 완료는 초록

가능한 한 확정형으로

불확정 막대가 알려 주는 건 앱이 아직 멈추지 않았다는 사실뿐이다. 서류 업로드나 여러 단계 인증처럼 실제 비율을 알 수 있으면 비율을 보여 준다. 비율을 알 수 없으면 애니메이션만 돌리기보다 단계 라벨("Checking recipient details")을 보여 주는 게 낫다. 뭔가 진행 중이라는 것만이 아니라, 무엇이 진행 중인지 알려 주기 때문이다.

구조

트랙, 채움, 그리고 옆에 붙는 텍스트 비율이나 단계 라벨로 이뤄진다. 텍스트는 필수다. 막대만 있으면 색각 이상이나 저시력 사용자는 읽을 수 없다.

변형

VariantDimensionUse
Linear determinatetype업로드, 인증처럼 실제 비율을 알 수 있을 때
Linear indeterminatetype비율을 알 수 없을 때. 단계 라벨을 함께 둔다
Circulartype인라인, 좁은 맥락

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한다.

상태

StateRendering
Indeterminatevalue 생략, aria-valuenow 없음. 막대만 두지 않고 단계 라벨을 함께 둔다
Determinatevalue%만큼 채우고, 막대 옆에 비율을 텍스트로 표시
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

Progress
Uploading ID document...35%
x
Transfer in progress70%
x
Complete100%
x

Skeleton

불러올 콘텐츠의 모양을 미리 흉내 내는 반짝이는 자리 표시.

  • 데이터를 가져오는 동안 쓴다
  • 최종 콘텐츠와 레이아웃을 맞춰 레이아웃 밀림을 막는다
  • 구조가 있는 콘텐츠 영역에서는 스피너 대신 쓴다
  • 원형(아바타), 사각형(텍스트), 카드 모양이 있다

스피너보다 스켈레톤인 이유

스켈레톤은 곧 나올 콘텐츠의 모양을 미리 보여 줘서 기다림을 짧게 느끼게 하고, 스피너가 콘텐츠로 바뀔 때 생기는 레이아웃 밀림도 막는다. Design principles의 "빈 화면은 절대 없다" 규칙과도 맞는다.

스켈레톤은 최종 레이아웃과 최대한 비슷해야 한다. 높이가 다르면 막으려던 콘텐츠 밀림이 그대로 생긴다. 돈을 보내는 플로우에서 누르려던 버튼이 움직이는 것만큼 나쁜 일은 없다.

구조

대신할 콘텐츠와 같은 크기로 --ds-surface를 채운 도형.

변형

VariantDimensionMatches
textlayout본문 줄 높이의 한 줄 이상
circlelayout아바타나 국기
rectlayout이미지나 차트 영역
cardlayout패딩까지 포함한 카드 전체

API

interface SkeletonProps {
  shape?: "text" | "circle" | "rect" | "card";  // default: "text"
  lines?: number;                   // shape="text" only
  width?: string;
  height?: string;
}

스켈레톤 높이는 최종 레이아웃과 같아야 한다. 높이가 다르면 막으려던 콘텐츠 밀림이 그대로 생긴다.

상태

StateRendering
Shimmering기본. 영역에 aria-busy="true"
Staticprefers-reduced-motion에서는 반짝임만 멈추고 자리 표시는 남는다

에러 상태는 따로 없다. 불러오기에 실패하면 스켈레톤을 남겨 두지 않고 에러 상태로 바꾼다.

동작

콘텐츠, 빈 상태, 에러 상태 중 하나로 반드시 바뀌고, 스켈레톤인 채로 남지 않는다. 셋 중 하나가 나타날 때까지 영역에 aria-busy를 둔다.

콘텐츠

없다. 텍스트가 들어가면 스켈레톤이 아니다.

접근성

  • 스켈레톤 영역에는 aria-busy="true"를, 안의 콘텐츠에는 aria-hidden을 준다.
  • polite 라이브 영역에서 "Loading transactions"를 한 번만 읽어 준다. 반짝일 때마다 읽지 않는다.
  • prefers-reduced-motion에서는 반짝임 애니메이션만 멈추고 자리 표시는 남긴다.

키보드

포커스를 받지 않고 상호작용하지 않는다. 자체 키보드 동작은 없다.

토큰

--ds-surface · --ds-radius-md · --ds-duration-slower

Skeleton

Inline loading

콘텐츠나 컨트롤 안에서 그 자리의 로딩을 표시하는 작은 스피너.

  • 쓰는 곳: 환율 불러오기, 수취인 정보 확인, 잔액 조회
  • 버튼 안, 텍스트 라벨 옆, 테이블 셀 안에 들어간다
  • 상태: loading(스피너), success(체크 표시로 전환), error(다시 시도)
  • 스피너 크기는 맥락에 맞춘다(버튼 안 14px, 텍스트 옆 16px)
  • 접근성: aria-live="polite"로 상태 변화를 알린다
  • 페이지의 다른 요소는 계속 조작할 수 있다

모든 스피너에는 타임아웃이 필요하다

실패 경로가 없는 인라인 스피너는 연결이 끊기면 끝없이 돈다. 그리고 Senco 사용자에게 연결 끊김은 일상이다. 그래서 모든 로딩 상태에는 타임아웃을 두고, 시간이 지나면 다시 시도 액션이 있는 에러 상태로 바꾼다. 끝없이 도는 스피너는 이 제품이 사용자의 돈이 어떻게 되고 있는지 거짓으로 알리게 되는 가장 흔한 경우다.

구조

맥락에 맞는 크기의 스피너, 그다음 라벨 순이다. 버튼 안에서는 스피너가 앞쪽 아이콘 자리에 들어가고 라벨은 그대로 둔다.

변형

VariantDimensionSize
In buttonlayout14px, 앞쪽 아이콘 자리
Beside textlayout16px, 라벨과 같은 줄
In celllayout16px, 값 자리

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
}

스피너는 반드시 끝나야 한다. 타임아웃이 없으면 연결이 끊겼을 때 끝없이 도는데, 이 제품 사용자에게 연결 끊김은 흔한 일이다.

상태

StateRendering
Loading스피너와 라벨. polite로 한 번 읽어 준다
Success체크 아이콘. 스피너를 남겨 두지 않는다
Error경고 아이콘과 다시 시도 액션
Timed outtimeoutMs가 지나면 에러 상태가 된다. 그 밖의 결과는 없다

동작

로딩 상태에 들어갈 때 한 번만 읽어 주고, 반복하지 않는다. timeoutMs 안에 성공이나 에러 둘 중 하나로 끝난다. 그 사이 페이지의 다른 곳은 계속 조작할 수 있다.

콘텐츠

라벨에는 "Loading"이 아니라 지금 무엇을 하는지 적는다("Checking recipient details").

접근성

  • 스피너는 aria-hidden이다. 상태는 polite 라이브 영역으로 알린다.
  • 스피너가 들어간 컨트롤은 disabled 대신 aria-disabled를 쓴다. 그래야 포커스를 받고, 상태 설명도 연결할 수 있다.
  • 동작 줄이기 설정에서는 스피너가 회전을 멈추고 정지된 아이콘으로 남는다. 상태는 라벨로 전하므로 라벨이 필수다.

키보드

로딩 표시 자체는 포커스를 받지 않는다. 다시 시도 액션은 Button이다.

토큰

--ds-primary · --ds-green-600 · --ds-red-600 · --ds-size-icon-sm

Inline loading
Inside button
Beside text
Verifying recipient...
Success state
Recipient verified

디자인과 접근성

  • Alert는 색, 아이콘, 텍스트를 함께 써서 상태를 전한다(색만으로는 안 된다)
  • 에러 요약은 페이지 맨 위에 나타나고, 각 에러로 가는 링크를 담는다
  • 에러 메시지는 aria-describedby로 입력과 연결한다
  • Details 컴포넌트는 네이티브 펼침 패턴을 쓴다
  • 동적으로 바뀌는 피드백은 aria-live 영역으로 스크린 리더에 알린다
  • 폼 제출에 실패하면 포커스가 에러 요약으로 간다
  • 다이얼로그는 포커스를 가두고, 닫히면 돌려준다

여기 모든 컴포넌트에 적용되는 규칙

  • 얼마나 오래 보일지는 심각도로 정한다. 사용자가 대응해야 하는 메시지는 저절로 사라지지 않는다.
  • 어디에 둘지는 범위로 정한다. 필드 에러는 필드 아래, 페이지 에러는 맨 위에 둔다.
  • 모든 메시지는 다음에 할 일을 알려 준다. 문제만 알리는 건 메시지의 절반일 뿐이다.
  • 중요한 정보를 잠깐 떴다 사라지는 곳에만 두지 않는다. 토스트나 툴팁만으로 전달하지 않는다.
  • 모든 로딩 상태에는 실패 상태가 있다. 타임아웃 없는 스피너는 없다.
  • 색, 아이콘, 텍스트는 항상 함께 쓴다.

권장과 금지

DoDon't
"Rate expired — refresh to see the current rate""Rate expired"
Let an error toast persist until dismissedAuto-dismiss a failure after 5 seconds
Focus the error summary on failed submitRender a summary nobody lands on
Focus the safe action in an alert dialogFocus "Delete" by default
Show the fee, hide only its breakdownHide the existence of a fee behind Details
Give every spinner a timeout and a retryShip a spinner with no failure path

알려진 공백

  • "Alert dialog"와 "Error summary"는 컴포넌트 표에는 있지만, 독립된 스펙 없이 하위 섹션으로만 문서화돼 있다.
  • 문서 사이트의 라이브 영역 프리뷰 키는 Notice인데 이 페이지의 제목은 Alert라서, 그 섹션은 시각 자료 없이 렌더링된다.
  • 컴포넌트별 스켈레톤 모양이 정해지지 않았다.
  • 토스트 쌓기 동작은 문서에만 있고 구현되지 않았다.