액션
액션을 실행하거나 사용자를 다른 페이지로 옮기는 컴포넌트.
디자인 시스템의 수준은 액션에서 드러난다. 사용자가 최종 결정을 내리는 지점이기 때문이다. Senco에서는 이 중 하나가 실제로 돈을 보낸다. 보낸 돈은 되돌릴 수 없고, 아래 규칙은 모두 그 전제에서 나왔다.
컴포넌트
| Component | Purpose | Element |
|---|---|---|
| Button | 액션을 실행한다 (제출, 취소, 삭제 등) | <button> |
| Toggle group | 뷰나 모드를 바꾸는, 서로 배타적인 버튼 그룹 | <div role="radiogroup"> 또는 role="group" |
| Link | 다른 페이지나 섹션으로 이동한다 | <a> |
버튼인가 링크인가
둘은 생김새가 아니라 의미로 구분한다. 잘못 고르면 키보드 동작과 스크린 리더 출력이 깨진다.
| Use | When | Key |
|---|---|---|
<button> | 무언가가 일어난다 (제출, 열기, 삭제, 토글) | Enter와 Space |
<a href> | 어딘가로 이동한다. URL이 바뀐다 | Enter만 |
버튼처럼 꾸며도 링크는 링크다. onClick을 단 <div>는 둘 다 아니다. 포커스, 키보드 실행, 역할을 한꺼번에 잃는다. 페이지를 이동하는 컨트롤이라면 새 탭으로 열어도 동작하는 href가 있어야 한다.
Button
구조
┌─────────────────────────────────────┐
│ [icon] Label [icon] │ min-height 48px
└─────────────────────────────────────┘
│ │ │
│ │ └── trailing icon (optional) — direction only
│ └── label — verb + noun, sentence case
└── leading icon (optional) — reinforces the action
| Part | Required | Token |
|---|---|---|
| Container | Yes | --ds-radius-md (6px) |
| Label | Yes | --ds-text-sm, --ds-font-semibold |
| Leading icon | No | --ds-size-icon (20px) |
| Trailing icon | No | --ds-size-icon (20px) |
| Min height | Yes | --ds-size-touch (48px) |
유형
button— 폼 제출 없이 액션을 실행한다submit— 폼을 제출한다reset— 폼 값을 초기화한다
Senco에서는 reset을 사실상 쓰지 않는다. reset 버튼은 submit 바로 옆에 똑같은 모양으로 놓여, 사용자가 4분 들여 채운 폼을 한 번에 날린다. 입력을 지우는 기능이 꼭 필요하면 링크 스타일로 만들어 주요 버튼과 떨어진 곳에 둔다.
역할
primary— 페이지의 주요 액션secondary— 보조 액션danger— 파괴적이거나 되돌릴 수 없는 액션
변형
| Variant | Background | Text | Border | Use |
|---|---|---|---|---|
| Primary | --ds-primary | --ds-on-accent | none | 그 화면의 목적인 단 하나의 액션 |
| Secondary | --ds-bg | --ds-heading | --ds-border | Primary 옆의 보조 액션 |
| Danger | --ds-red-600 | --ds-on-error | none | 파괴적이고 되돌릴 수 없음 |
| Link | transparent | --ds-link | none | 3순위 액션. 아래 Link 참고 |
Primary는 화면당 하나다. Primary가 둘이면 행동 유도가 강해지는 게 아니라 아예 사라진다.
크기
| Size | Min height | Radius | Padding | Token set |
|---|---|---|---|---|
| Default | 48px (--ds-size-touch) | --ds-radius-md (6px) | --ds-space-5 가로 | 없음. semantic과 state 토큰을 조합 |
| Pill | 48px (--ds-pillbtn-h) | --ds-radius-full | --ds-pillbtn-px (24px) | --ds-pillbtn-* |
| Action tile | 72px (--ds-actionbtn-min-h) | --ds-radius-full | --ds-space-4 | --ds-actionbtn-* |
셋 모두 라벨에 --ds-text-sm / 600을 쓴다.
한 컴포넌트의 세 가지 크기가 아니라, 이름만 같은 세 컴포넌트다. Default 버튼은 6px 사각형, Pill은 모바일 액션 바에 쓰는 48px 캡슐이다. Action tile은 라벨 위에 40px 원형 아이콘을 얹은 72px 카드로, 대시보드 빠른 액션 줄에서만 쓴다. --ds-actionbtn-*은 타일 전용이다. 이 표는 타일의 radius-full을 Default 버튼에 가져다 쓰는 실수를 막으려고 만들었다.
작은 버튼은 없다. 36px 컨트롤은 48px 터치 기준을 통과하지 못한다. 작은 버튼을 쓸 법한 빽빽한 행 액션에는 터치 영역을 넓힌 아이콘 버튼을 쓴다.
API
type ButtonVariant = "primary" | "secondary" | "danger" | "link";
type ButtonSize = "default" | "pill" | "tile";
interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "disabled"> {
variant?: ButtonVariant; // default: "secondary"
size?: ButtonSize; // default: "default"
type?: "button" | "submit"; // default: "button" — "reset" is not offered
leadingIcon?: React.ReactNode;
trailingIcon?: React.ReactNode;
fullWidth?: boolean; // default: false — true in the mobile action bar
loading?: boolean; // default: false
loadingLabel?: string; // default: "Working" — announced, not rendered
disabled?: boolean; // rendered as aria-disabled, never the disabled attribute
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode; // the label; required, never icon-only
}
| Prop | Contract |
|---|---|
variant | variant 축은 하나뿐이다. 이 페이지에서 말하는 "역할"도 같은 축을 달리 부른 이름이다 |
size | "tile"을 주면 Tokens에서 설명한 Action tile 컴포넌트가 된다. 아이콘을 라벨 위에 렌더링한다 |
disabled | aria-disabled="true"를 붙이고 실행을 막는다. 네이티브 disabled 속성은 쓰지 않으므로 컨트롤이 계속 포커스를 받고, 비활성 이유도 설명할 수 있다 |
loading | 호출자가 제어한다. disabled처럼 동작하고, 라벨은 그대로 두고, leadingIcon 자리에 스피너를 띄운다. loading이 false로 돌아와야 버튼이 다시 풀린다. 요청과 10초 타임아웃은 호출자 책임이고, 성공·실패·타임아웃 어느 경우든 호출자가 loading을 되돌린다 |
children | 아이콘만 있는 버튼은 이 컴포넌트로 만들지 않는다. 그건 자체 aria-label을 갖는 Icon button이다 |
aria-disabled 버튼은 탭 순서에 그대로 남는다. onClick도 실행되고, Enter와 Space에도 반응하고, 폼도 제출한다. 이 넷을 모두 막는 건 컴포넌트가 할 일이다. 시스템 곳곳에 나오는 aria-disabled 규칙은 이 처리를 전제로 하고, 가장 자주 빠뜨리는 부분도 여기다.
상태
Default 버튼은 어떤 상태에서도 그림자를 쓰지 않는다. 그림자는 Action tile의 것이고, Action tile은 자체 토큰을 쓰는 별개 컴포넌트다.
| State | Primary | Secondary | Danger | Link |
|---|---|---|---|---|
| Default | bg --ds-primary, text --ds-on-accent | bg --ds-bg, text --ds-heading, 1px --ds-border | bg --ds-red-600, text --ds-on-error | text --ds-link, 밑줄 |
| Hover | bg --ds-primary-hover | bg --ds-surface | bg --ds-red-700 | text --ds-primary-hover |
| Active | hover와 같음 | hover와 같음 | hover와 같음 | hover와 같음 |
| Focus-visible | 3px 실선 --ds-focus 외곽선, 2px 오프셋 | 같음 | 같음 | 같음 |
| Disabled | bg --ds-disabled-bg, text --ds-disabled-text | 같음 | 같음 | text --ds-gray-400, 밑줄 없음 |
| Loading | 앞쪽 자리에 --ds-size-icon(20px) LoaderCircle, currentColor, 1s linear 회전. 라벨 유지, variant 색 유지. loading은 disabled 스타일을 쓰지 않는다 | 같음 | 같음 | 제공하지 않음 |
전환 애니메이션은 background-color, color, border-color에 --ds-duration-fast와 --ds-ease-default를 적용한다. Danger variant의 --ds-red-600 / --ds-red-700은 Base 토큰을 직접 참조한다(알려진 공백 참고).
Default와 Pill의 세로 패딩은 --ds-space-3이다. 높이는 최소 48px이 기준이고, 라벨이 두 줄이 되면 한 줄 높이만큼 버튼이 커진다. 가로 패딩은 Default가 --ds-space-5, Pill이 --ds-pillbtn-px다.
Pill과 tile 크기는 각각 두 가지 variant를 지원한다.
| Size | Variant | Default | Hover |
|---|---|---|---|
| Pill | Primary | bg --ds-pillbtn-primary-bg, text --ds-pillbtn-primary-text | bg --ds-primary-hover |
| Pill | Secondary | bg --ds-pillbtn-secondary-bg, text --ds-pillbtn-secondary-text, 1px --ds-pillbtn-secondary-border | bg --ds-primary-light |
| Tile | Primary | --ds-actionbtn-primary-bg, -text, -shadow | --ds-actionbtn-primary-bg-hover, -shadow-hover |
| Tile | Secondary | --ds-actionbtn-secondary-bg, -text, -border, -shadow | --ds-actionbtn-secondary-bg-hover, -border-hover, -shadow-hover |
Danger와 Link는 pill이나 tile 크기로 제공하지 않는다. 파괴적 액션을 액션 바의 눈에 띄는 pill이나 대시보드 타일로 만들 일은 없다.
loading이 true인 동안 버튼은 화면에 보이지 않는 role="status" 형제 요소에 loadingLabel을 넣어, 로딩 시작을 한 번 읽어 준다. 로딩이 끝났다는 안내는 버튼이 아니라 결과를 보여 주는 쪽이 맡는다.
로딩 중에도 라벨은 그대로 둔다. "Send 250 USD"를 스피너로 바꿔 버리면, 무엇을 보내는지 가장 확인하고 싶은 순간에 그걸 확인할 유일한 단서가 사라진다.
동작
- 로딩 중인 버튼은
aria-disabled상태로 추가 클릭을 무시하지만, 포커스는 계속 받는다. - 폭은 콘텐츠에 맞춘다. 모바일 액션 바의 Primary 버튼만 전체 폭이다.
- 라벨은 자르지 않는다. 번역으로 길어진 라벨은 두 줄로 넘기고 버튼을 키운다. 라벨을 자르면 정작 그 라벨이 가장 필요한 언어에서 읽을 수 없게 된다.
- 중복 제출은 호출자가 막는다. 누름 핸들러는 첫
await전에loading을 동기적으로true로 바꾼다.loading이 true인 동안 버튼은 실행되지 않으므로, 같은 프레임에 들어온 두 번째 탭은 무시된다. 응답이 오거나 10초 타임아웃이 지나면 호출자가loading을false로 되돌린다.
콘텐츠
- 동사 + 명사, 문장형 대소문자: "Send money", "Add recipient", "Delete contact"
- 처리 방식 말고 대상을 쓴다: "Submit transfer request"가 아니라 "Send 250 USD"
- "OK", "Yes", "Submit", "Confirm"만 달랑 쓰지 않는다. 다이얼로그 버튼도 액션을 다시 적어야 맥락 없이 읽어도 뜻이 통한다
- 최대 네 단어
| Do | Don't |
|---|---|
| "Send 250.00 USD" | "Confirm" |
| "Delete recipient" | "Yes" |
| "Save changes" | "Submit" |
| "Try again" | "Retry operation" |
접근성
- 최소 터치 타깃: 48×48px
<button>을 쓴다. 스타일만 입힌<div>나href없는<a>는 쓰지 않는다- 아이콘만 있는 버튼에는
aria-label을 붙인다 - 비활성 버튼은
disabled속성 대신aria-disabled를 쓴다. 그래야 포커스를 받고, 비활성 이유를aria-describedby로 알려 줄 수 있다 - 키보드로 이동할 때 포커스 표시가 보여야 한다.
:focus-visible을 쓴다 - Danger 버튼으로 파괴적 액션을 실행할 때는 확인 단계를 거친다
- 로딩 상태 변화는 액션 도중 접근 가능한 이름을 바꾸지 말고
aria-live="polite"영역으로 알린다
| Key | Action |
|---|---|
Tab | 버튼으로 포커스 이동 |
Enter | 실행 |
Space | 실행 |
토큰
Default 크기: --ds-primary · --ds-primary-hover · --ds-on-accent · --ds-bg · --ds-surface · --ds-heading · --ds-border · --ds-red-600 · --ds-red-700 · --ds-on-error · --ds-link · --ds-focus · --ds-focus-ring-width · --ds-focus-ring-offset · --ds-disabled-bg · --ds-disabled-text · --ds-radius-md · --ds-size-touch. Pill 크기는 --ds-pillbtn-*을 더하고, tile 크기는 이 대신 --ds-actionbtn-*을 쓴다.
알려진 공백
- 앱 바에서 이미 쓰는 패턴인데도 아이콘 전용 버튼 variant가 정의되지 않았다.
- 버튼 그룹(관련 버튼 여러 개를 한 줄에 놓는 것)은 아직 문서가 없다.
- 1Container
- 2Label text
- 3Leading icon
- 4Focus ring
- 5Touch target area
Toggle group
한 번에 하나만(또는 하나도 없이) 켜지는 토글 버튼 묶음.
- 뷰 전환(List / Grid), 기간 선택(Day / Week / Month)에 쓴다
- 기본은 단일 선택이고, 다중 선택도 지원할 수 있다
- 아이콘형과 텍스트형을 지원한다
- 활성 항목은 비활성 항목과 한눈에 구분된다
- 그룹 안에서 화살표 키로 이동할 수 있다
구조
┌──────────┬──────────┬──────────┐
│ Money │ Crypto │ │ one Tab stop
└──────────┴──────────┴──────────┘
▲
└── active: --ds-primary-light bg, --ds-primary text
변형
| Variant | Selection | Use |
|---|---|---|
| Single | 정확히 하나 활성 | Money / Crypto 송금 경로 선택 |
| Single, deselectable | 0개 또는 1개 활성 | 선택적 필터 |
| Multi | 개수 제한 없이 활성 | 거래 유형 필터 |
API
interface ToggleGroupProps<T extends string = string> {
label: string; // accessible name for the group — required
items: Array<{
value: T;
label: string;
icon?: React.ReactNode;
disabled?: boolean;
}>;
selectionMode?: "single" | "single-deselectable" | "multi"; // default: "single"
value: T | T[] | null; // T for single, T[] for multi, null when deselected
onValueChange: (value: T | T[] | null) => void;
fullWidth?: boolean; // default: true on viewports below --ds-bp-mobile
}
selectionMode가 variant 축이고, 값에 따라 ARIA 계약이 달라진다. single은 role="radiogroup"과 aria-checked를, multi는 role="group"과 항목별 aria-pressed를 렌더링한다. 모드를 바꾸면 동작뿐 아니라 시맨틱도 바뀐다.
상태
| State | Visual |
|---|---|
| Inactive | --ds-bg, --ds-muted 라벨 |
| Active | --ds-primary-light 채움, --ds-primary 라벨, 굵기 600 |
| Hover | 비활성 항목에만 --ds-surface 채움 |
| Focus-visible | 그룹이 아니라 포커스된 항목에 3px 실선 --ds-focus 외곽선, 2px 오프셋 |
| Disabled item | --ds-disabled-text, 화살표 키 이동에서 건너뜀 |
활성 상태는 채움만으로 표시하지 않고 굵기도 함께 바꾼다. 제2색맹 시뮬레이션에서는 흰 바탕 위 연한 파란 채움이 명도 차이로 거의 보이지 않지만, 굵기 차이는 그대로 보인다.
동작
- 항목을 고르면 즉시 적용된다. 토글 그룹은 제출을 기다리는 폼 필드가 아니다.
- 보이는 항목이 하나도 없는 그룹은 없다. 비어 있다면 빈 상태가 아니라 버그다.
- 모바일에서는 그룹을 전체 폭으로 두고 항목 폭을 똑같이 나눈다. 항목 폭을 콘텐츠에 맞추면 오른쪽 끝이 들쭉날쭉해서 정렬이 깨진 것처럼 보인다.
콘텐츠
항목 라벨은 한두 단어로 쓰고 형태를 맞춘다. "Money"와 "Send with crypto"가 아니라 "Money"와 "Crypto"다.
접근성
- 그룹 전체가 Tab 정지점 하나다. 화살표 키가 안에서 이동한다.
- 단일 선택은
role="radiogroup"안에role="radio"자식을 두고aria-checked를 쓴다. - 다중 선택은
role="group"을 쓰고 각<button>에aria-pressed를 단다. - 그룹에는
aria-label이나aria-labelledby로 접근 가능한 이름을 붙인다. - 아이콘만 있는 항목에는 항목마다
aria-label을 붙인다.
| Key | Action |
|---|---|
Tab | 그룹 안으로 / 밖으로 포커스 이동 |
← → | 항목 사이 이동 (끝에 닿으면 처음으로) |
Home / End | 첫 / 마지막 항목 |
Space | 포커스된 항목 토글 (다중 선택) |
단일 선택에서는 화살표 키가 포커스와 선택을 함께 옮긴다. 표준 라디오 그룹 동작이고, 스크린 리더 사용자도 이렇게 동작하리라 예상한다.
토큰
--ds-primary-light · --ds-primary · --ds-surface · --ds-border · --ds-radius-md · --ds-focus-ring
알려진 공백
- 다중 선택은 문서에만 있고, 출시된 화면 중 구현한 곳은 없다.
- 항목이 다섯 개 이상일 때의 넘침 처리가 정해지지 않았다.
Link
보조 내비게이션이나 맥락에 따른 작업에 쓰는 인라인 텍스트 액션. 링크 버튼은 모두 <Button variant="link">로 만든다.
사용
- 주요 버튼 아래의 보조 액션에 쓴다 (Back to home, Edit payment method)
- 되돌릴 수 없는 액션에는 destructive variant를 쓴다 (Delete contact, Remove method, Cancel transfer)
- 아이콘은 항상 inline-start(텍스트 왼쪽)에 둔다
- 페이지의 주요 액션에는 쓰지 않는다. 그때는
<Button>을 쓴다
구조
inline-start 아이콘(선택), 그다음 라벨 순이다. 링크의 inline-end에는 아이콘을 두지 않는다. 그 자리는 외부 링크 표시 몫이다.
변형
| Variant | Class | Color | Example |
|---|---|---|---|
| Default | base | var(--ds-link) | Forgot password?, Back to home |
| With icon | base | var(--ds-link) | + Add payment method |
| Destructive | .link-destructive | var(--ds-red-600) | Delete contact, Remove method |
스타일
| Property | Inline link | Standalone link |
|---|---|---|
| 글자 크기 | var(--ds-text-sm) — 14px | var(--ds-text-sm) — 14px |
| 글자 굵기 | var(--ds-font-semibold) — 600 | var(--ds-font-semibold) — 600 |
| 기본 상태 | 밑줄, 오프셋 2px | 밑줄, 오프셋 2px |
| Hover | 색 var(--ds-primary-hover) | 색 var(--ds-primary-hover) |
| Focus-visible | 3px 실선 var(--ds-focus) 외곽선, 2px 오프셋 | 3px 실선 var(--ds-focus) 외곽선, 2px 오프셋 |
| Disabled | var(--ds-gray-400), 밑줄 없음 | var(--ds-gray-400), 밑줄 없음 |
| 높이 | auto, 텍스트 흐름을 따른다 | 최소 높이 48px (h-12) |
| 패딩 | 가로 0, 세로 0 | 가로 0, 48px에 닿을 만큼 세로 패딩 |
두 링크의 차이는 값이 다른 이 두 행이 전부다. Inline 링크는 문장 안에 들어간다. 48px 상자를 주면 주변 문단의 줄 높이가 깨지므로, 터치 영역은 텍스트 자체다. Standalone 링크는 한 줄을 혼자 차지하는 액션이라 다른 컨트롤과 똑같이 48px 타깃을 갖는다.
예전 표는 height: auto, padding: 0 한 가지 값만 적어 두고, 아래 접근성 섹션에서는 패딩으로 48px 타깃을 확보하라고 했다. 둘은 동시에 성립할 수 없다. 알고 보니 두 규칙은 서로 다른 링크를 말하고 있었다.
인라인 링크에는 항상 밑줄을 긋는다
문단 안의 링크에는 밑줄을 긋는다. 색만으로는 부족하다. 색각 이상이 있는 독자 기준으로 SC 1.4.1을 통과하지 못하고, 저대비 화면을 햇빛 아래서 보는 사람도 구분하지 못한다. 텍스트 블록 밖에 따로 놓인 링크 스타일 액션은 위치와 간격만으로 액션임이 드러나므로 밑줄을 빼도 된다.
API
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string; // required — a control without a destination is a Button
placement?: "inline" | "standalone"; // default: "inline"
tone?: "default" | "destructive"; // default: "default"
icon?: React.ReactNode; // rendered inline-start only
external?: boolean; // default: false — adds the external mark, target, rel, and "opens in a new tab"
children: React.ReactNode;
}
높이 계약은 placement로 정리한다. inline은 height: auto로 텍스트 흐름을 따르고, standalone은 최소 48px 타깃을 갖는다. 취향대로 고르는 스타일 옵션이 아니라 반드시 정해야 하는 값이다.
링크처럼 생겼지만 이동 대신 액션을 실행하는 컨트롤은 <Button variant="link">로 만들고 ButtonProps를 받는다. 둘은 바꿔 쓸 수 없다. 하나는 Space로 실행되고 다른 하나는 그렇지 않다.
상태
| State | Visual |
|---|---|
| Default | --ds-link, 밑줄, 2px 오프셋 |
| Hover | --ds-primary-hover, 밑줄 유지 |
| Focus-visible | 3px 실선 --ds-focus 외곽선, 2px 오프셋. 밑줄 유지 |
| Visited | 별도 스타일 없음. 앱 안의 액션에는 "방문함"이라는 개념이 없다 |
| Disabled | --ds-gray-400, 밑줄 없음, aria-disabled |
동작
- Standalone 링크는 텍스트 높이가 21px이어도 패딩으로 48px 터치 영역을 확보한다.
- 외부 링크는 외부 링크 아이콘을 달고
rel="noopener"로 새 탭에서 열린다. 접근 가능한 이름에 "opens in a new tab"을 넣는다. - 파괴적 링크도 확인 단계를 거친다. 생김새가 가볍다고 액션의 무게까지 가벼워지지는 않는다.
콘텐츠
- 링크 텍스트만 읽어도 액션이나 목적지를 알 수 있어야 한다
- "click here", "read more", "learn more"는 쓰지 않는다
- 스크린 리더 사용자는 링크 목록을 훑으며 탐색한다. "Learn more"가 네 개 있는 페이지에는 쓸모없는 링크가 네 개 있는 셈이다
| Do | Don't |
|---|---|
| "Edit payment method" | "Edit" |
| "See all transfers" | "More" |
| "How fees are calculated" | "Learn more" |
| "Delete this recipient" | "Remove" |
접근성
- 링크 텍스트로 액션이나 목적지를 알 수 있어야 한다
- 뜻 없는 문구("click here", "read more")는 피한다
- 파괴적 링크는 색과 텍스트 둘 다로 의도를 드러낸다(색만으로는 안 된다)
- 최소 터치 타깃: standalone일 때 높이 48px (
h-12클래스를 쓴다) - 키보드로 이동할 때 포커스 표시가 보여야 한다
- 이동 없이 액션만 실행한다면 링크를 쓸 자리가 아니다. link variant
<button>을 쓴다
| Key | Action |
|---|---|
Tab | 링크로 포커스 이동 |
Enter | 실행 |
Space로는 링크가 실행되지 않는다. Space에 반응해야 하는 컨트롤이라면 버튼이다.
토큰
--ds-link · --ds-primary-hover · --ds-red-600 · --ds-gray-400 · --ds-text-sm · --ds-font-semibold · --ds-focus · --ds-focus-ring-width · --ds-focus-ring-offset
알려진 공백
.link-destructive는 클래스로만 문서화돼 있고, 짝이 되는--ds-link-destructive토큰은 없다.- 외부 링크 처리는 이 문서에만 정의돼 있고, 컴포넌트 prop으로는 아직 구현되지 않았다.
View your transaction history or download a statement.