Stat
수치 통계를 레이블, 값, 트렌드와 함께 표시하는 컴포넌트
목차
대시보드나 리포트에서 주요 수치를 레이블, 값과 단위, 변동 지표, 설명 줄과
함께 표시하는 통계 컴포넌트입니다. Flutter / Web 양쪽에서 동일한 Stat
API 를 사용합니다.
기본 사용법 (Basic Usage)#
// 기본 통계
Stat(
label: '총 사용자',
value: '128,450',
unit: '명',
)
// 상승 트렌드 + 설명 줄
Stat(
label: '이번 달 매출',
value: '12,450,000',
unit: '원',
change: '+12.5%',
changeType: CoreStatChangeType.positive,
description: '환불 전 금액',
)
// 하락 트렌드
Stat(
label: '이탈률',
value: '3.2%',
change: '-0.8%',
changeType: CoreStatChangeType.negative,
)
// 중립 트렌드
Stat(
label: '평균 응답 시간',
value: '245ms',
change: '변동 없음',
changeType: CoreStatChangeType.neutral,
)
// 기본 통계
Stat(
label: '총 사용자',
value: '128,450',
unit: '명',
)
// 상승 트렌드 + 설명 줄
Stat(
label: '이번 달 매출',
value: '12,450,000',
unit: '원',
change: '+12.5%',
changeType: CoreStatChangeType.positive,
description: '환불 전 금액',
)
// 하락 트렌드
Stat(
label: '이탈률',
value: '3.2%',
change: '-0.8%',
changeType: CoreStatChangeType.negative,
)
// 중립 상태
Stat(
label: '평균 응답 시간',
value: '245ms',
change: '변동 없음',
changeType: CoreStatChangeType.neutral,
)
Live Preview#
코드 보기
class StatDefaultExample extends StatefulComponent {
const StatDefaultExample({super.key});
@override
State<StatDefaultExample> createState() => _StatDefaultExampleState();
}
class _StatDefaultExampleState extends State<StatDefaultExample> {
@override
Component build(BuildContext context) {
return Stat(
label: 'Total Revenue',
value: '45,231',
unit: 'USD',
change: '+20.1%',
changeType: CoreStatChangeType.positive,
description: 'Before refunds',
);
}
}
class StatDefaultExample extends StatefulWidget {
const StatDefaultExample({super.key});
@override
State<StatDefaultExample> createState() => _StatDefaultExampleState();
}
class _StatDefaultExampleState extends State<StatDefaultExample> {
@override
Widget build(BuildContext context) {
return const Stat(
label: 'Total Revenue',
value: '45,231',
unit: 'USD',
change: '+20.1%',
changeType: CoreStatChangeType.positive,
description: 'Before refunds',
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 대시보드의 KPI(핵심 성과 지표)를 카드 형태로 표시할 때
- 매출, 사용자 수, 방문자 수 등 주요 지표를 강조하여 표시할 때
- 이전 기간 대비 증감 트렌드를 함께 보여줄 때
- 분석 리포트의 요약 수치를 그리드로 나열할 때
대신 다른 컴포넌트를 사용하세요:
Progress: 완료율이나 목표 달성률을 시각적으로 표현할 때NumberTicker: 단순히 애니메이션 숫자만 필요할 때 (레이블, 트렌드 불필요)Text: 레이블 없이 수치만 간단히 표시할 때
Props / Parameters#
W는 icon 합성 슬롯 타입으로 Flutter의 Widget, Web의 Component입니다. value
· unit · change · description 은 이미 포맷된 문자열이며, 숫자 계산과 포맷은 호출자가 담당합니다. 단위는
value 에 붙여 쓰지 않고 unit 으로 넘기면 값과 같은 글자 바닥선에 한 단계 작게 그려집니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | String | 필수 | 통계 항목 레이블 |
value |
String? |
null |
표시할 수치 텍스트.
null
이면 값 자리에
CoreStatContract.defaultEmptyValue
(
—
)를
emptyValueColor
로 그립니다
|
unit |
String? |
null |
값 뒤에 같은 글자 바닥선으로 붙는 단위 (
명
·
%
·
원
). 값이
null
이면 그리지 않습니다
|
labelMaxLines |
int? |
null |
라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다 |
labelOverflow |
CoreTextOverflow? |
null |
labelMaxLines
에 도달한 라벨의 넘침 처리 (
clip
/
fade
/
ellipsis
/
visible
)
|
variant |
CoreStatVariant |
outlined |
카드 변형 —
outlined
(페이지 면 + 1px
outline-variant
선) ·
filled
(톤으로 구분하는 면, 선 없음)
|
size |
CoreComponentSize |
md |
카드 크기 —
md
· 대시보드의 좁은 칸용
sm
(여백 14 · 16, 줄 간격 4, 값
titleLarge
— 20 · SemiBold). 적지 않은 크기는 그 아래의 가장 가까운 크기로 그려집니다
|
change | String? | null | 변동 텍스트 |
changeType |
CoreStatChangeType? |
null |
변동 종류 (positive / negative / neutral) — 색상과 화살표를 결정 |
description |
String? |
null |
변동 줄 아래의 설명 줄 — 수치의 맥락(기간 · 기준). 화살표와 추세 색이 없습니다 |
icon |
W? |
null |
우상단 trailing 아이콘 |
statStyle |
CoreStatStyle? |
null |
per-instance chrome 오버라이드 (padding / borderRadius / colours / typography 등) |
빠른 오버라이드 (Chain)#
이미 만든 Stat 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 ==
CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class StatChainExample extends StatefulWidget {
const StatChainExample({super.key});
@override
State<StatChainExample> createState() => _StatChainExampleState();
}
class _StatChainExampleState extends State<StatChainExample> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
const Stat(
label: 'Total Revenue',
value: '\$45,231.89',
change: '+20.1%',
changeType: CoreStatChangeType.positive,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
const Stat(
label: 'Total Revenue',
value: '\$45,231.89',
change: '+20.1%',
changeType: CoreStatChangeType.positive,
).withStyle(
const CoreStatStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
);
}
}
class StatChainExample extends StatefulComponent {
const StatChainExample({super.key});
@override
State<StatChainExample> createState() => _StatChainExampleState();
}
class _StatChainExampleState extends State<StatChainExample> {
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
Stat(
label: 'Total Revenue',
value: '\$45,231.89',
change: '+20.1%',
changeType: CoreStatChangeType.positive,
).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
Stat(
label: 'Total Revenue',
value: '\$45,231.89',
change: '+20.1%',
changeType: CoreStatChangeType.positive,
).withStyle(
const CoreStatStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
classes: 'flex flex-col items-start',
);
}
}
마케팅 KPI — display 타이포#
값의 기본 역할은 headlineLarge(32 / bold)입니다. 홈 KPI 처럼 더 큰 숫자가 필요하면 valueTextStyle
에 기존 스케일 토큰을 넣습니다. 별도 display-size 축은 없습니다. CoreTextStyles.displaySmall 은 44 / bold / h140 / 자간 −2% 이고, Web 클래스는
text-display-small 입니다.
Stat(
label: '활성 사용자',
value: '128,450',
statStyle: const CoreStatStyle(
valueTextStyle: CoreTextStyle.token(CoreTextStyles.displaySmall),
),
)
text-headline-large 가 값 요소에 붙어 있으면, 부모에 거는 제품 CSS 의 font-size 는 상속이 끊겨 !important
로만 이깁니다. 슬롯은 그 역할 클래스를 text-display-small 로 바꿉니다.
단위와 설명 줄#
unit 은 값 뒤에 4 간격(valueUnitGapStyle)을 두고 값과 같은 글자 바닥선에 붙습니다. 역할은 titleMedium(18)에 굵기 500 이고 색은
onSurfaceVariant 입니다 — 값(32 · 700 · onSurface)보다 한 단계 아래에서 수치를 꾸밉니다. 라벨(bodyMedium)과
description(bodySmall)은 Regular 이고 값 · 단위 · 변동은 그보다 무겁게 그려 위계를 만듭니다. description
은 변동 줄 아래의 각주로 onSurfaceSubtle 로 그립니다. 기간이나 기준처럼 수치의 맥락을 담고, change 와 달리 화살표와 추세 색이 붙지 않습니다.
Stat(
label: '활성 사용자',
value: '128,450',
unit: '명',
change: '+5.2%',
changeType: CoreStatChangeType.positive,
description: '지난 30일',
)
줄은 위에서부터 레이블 → 값(+ 단위) → 변동 → 설명 순서이고, 사이 간격은 모두 contentGapStyle(10)입니다. 빈 줄은 간격까지 함께 빠집니다.
긴 값은 카드 안에서 줄바꿈되고, 단위는 값의 첫 줄과 기준선을 맞춰 그 줄 오른쪽에 놓입니다(마지막 줄이 아닙니다) — Flutter 는 값이 Flexible
이고, Web 은 값 요소가 min-w-0 break-words 이며, 두 플랫폼 모두 첫 기준선으로 정렬합니다.
빈 값#
수치를 아직 모르거나 없으면 value 를 생략합니다. 값 자리에 CoreStatContract.defaultEmptyValue(—)가 값의 역할 그대로, 색만
emptyValueColor(onSurfaceVariant)로 그려지고 단위는 붙지 않습니다. 카드 전체를 흐리게 하지 않으므로 레이블과 설명은 그대로 읽힙니다.
Stat(
label: '이번 달 매출',
unit: '원',
description: '아직 집계되지 않았습니다',
)
카드 변형 — outlined · filled#
기본 variant 는 outlined 입니다 — surface 면에 outline-variant 1px 선을 둡니다(입력 필드와 같은 단계).
filled 는 surfaceContainerLow 면에 선이 없고, 카드는 페이지보다 한 단계 올린 면의 톤으로 구분됩니다 — 그림자가 있는 카드라 한 단계면 충분합니다. 카드 상자는 두 변형 모두
OutlinedContainer 가 그리고, 반경·선·채움은 스타일 슬롯이 그 상자로 전달됩니다.
떠 있는 판(Dialog · Popup 의 surfaceBright) 안에서는 filled
카드가 판보다 어두운 면으로 놓입니다. 판 안에서는 기본 outlined 를 쓰세요.
Stat(
label: '활성 사용자',
value: '128,450',
unit: '명',
variant: CoreStatVariant.filled,
)
면까지 없앤 카드가 필요하면 스타일 슬롯으로 채움과 반경을 지웁니다.
Stat(
label: '활성 사용자',
value: '128,450',
statStyle: const CoreStatStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius0),
backgroundColor: CoreColor.transparent,
valueTextStyle: CoreTextStyle.token(CoreTextStyles.displaySmall),
),
)
이전 API 에서 옮기기 (Migration)#
-
기본 변형이
filled에서outlined로 바뀌었습니다.variant를 적지 않은Stat은 이제surface면에outline-variant선으로 그려집니다. 옛 모습을 그대로 두려면variant: CoreStatVariant.filled를 적습니다. -
size파라미터(md·sm)가 생겼습니다. 기본md는 이전 크기 그대로이고, 라벨 · 설명 글자만 Regular 로 내려갔고 단위는 Medium 으로 한 단계 가벼워졌습니다. -
CoreStatVariant에filled값이 생겼습니다. 이 enum 을 빠짐없이 나누는switch는filled갈래가 필요합니다. -
value가String?이 됐습니다. 넘기는 쪽은 그대로 동작하고,CoreStatContract를 직접 구현하거나stat.value를 읽는 코드는null을 다뤄야 합니다. 계약에는unit·description이 더해졌습니다. -
단위를
value문자열에 붙여 쓰던 자리('12,480명')는value: '12,480', unit: '명'으로 나누면 단위가 한 단계 작게 바닥선에 맞춰 그려집니다. 붙여 쓴 문자열도 그대로 동작합니다.
스타일 시스템 (Style System)#
CoreStatStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
padding |
CoreEdgeInsets? |
Inner padding around the stat content. |
borderRadius |
CoreBorderRadius? |
Outer border radius of the stat card. |
borderColor | CoreColor? | Outer border colour. |
borderWidth |
double? |
Outer border width (logical px). |
backgroundColor |
CoreColor? |
Card background colour. |
contentGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the 1-off gap between the content rows of the card (label-row ↔ value-row ↔ change-row ↔ description). Forwarded straight to the
Gap
widget that separates the rows.
null
defers to [defaultContentGapStyle].
|
changeIconGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the 1-off inner gap between the change arrow glyph and the change text inside the change row. Forwarded straight to the
Gap
widget.
null
defers to [defaultChangeIconGapStyle].
|
valueUnitGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the 1-off gap between the value and its unit inside the value row. Forwarded straight to the
Gap
widget.
null
defers to [defaultValueUnitGapStyle].
|
labelColor | CoreColor? | Label text colour. |
valueColor | CoreColor? | Value text colour. |
unitColor | CoreColor? | Unit text colour. |
emptyValueColor |
CoreColor? |
Colour of the empty-value placeholder drawn when the stat has no value. The placeholder keeps the value's text style; only the colour steps down. |
positiveColor |
CoreColor? |
Colour of the change indicator when changeType == positive. |
negativeColor |
CoreColor? |
Colour of the change indicator when changeType == negative. |
neutralColor |
CoreColor? |
Colour of the change indicator when changeType == neutral. |
descriptionColor |
CoreColor? |
Description (footnote) text colour. |
labelTextStyle |
CoreTextStyle? |
Label text style override. |
valueTextStyle |
CoreTextStyle? |
Value text style override. |
unitTextStyle |
CoreTextStyle? |
Unit text style override. |
changeTextStyle |
CoreTextStyle? |
Change indicator text style override. |
descriptionTextStyle |
CoreTextStyle? |
Description (footnote) text style override. |
CoreStatStyle 변형별 기본값 (CoreStatVariantStyle)#
| 필드 | outlined | filled |
|---|---|---|
backgroundColor |
surface | surfaceContainerLow (fallback: surface) |
borderColor | outlineVariant | transparent |
borderWidth | stroke1 | 0 |
변형 (Variants)#
상승 트렌드#
Stat(
label: '신규 가입',
value: '1,284',
change: '+8.2%',
changeType: CoreStatChangeType.positive,
)
하락 트렌드#
Stat(
label: '취소율',
value: '1.4%',
change: '-0.3%',
changeType: CoreStatChangeType.negative,
)
중립#
Stat(
label: '활성 세션',
value: '892',
change: '±0%',
changeType: CoreStatChangeType.neutral,
)
그리드 레이아웃#
여러 Stat 을 그리드로 배치하여 대시보드를 구성합니다.
GridView.count(
crossAxisCount: 3,
children: [
Stat(
label: '총 사용자',
value: '128,450',
change: '+5%',
changeType: CoreStatChangeType.positive,
),
Stat(
label: '매출',
value: '₩12.4M',
change: '+12%',
changeType: CoreStatChangeType.positive,
),
Stat(
label: '이탈률',
value: '3.2%',
change: '-0.8%',
changeType: CoreStatChangeType.negative,
),
],
)
동작 스펙 (Behavior)#
인터랙션#
Stat은 기본적으로 표시 전용 컴포넌트입니다.
표시#
- 줄 순서는 레이블 → 값(+ 단위) → 변동 → 설명이고, 줄 사이는
contentGapStyle입니다. 넘기지 않은 줄은 간격과 함께 빠집니다. -
단위는 값과 같은 글자 바닥선(alphabetic baseline)에
valueUnitGapStyle만큼 떨어져 붙습니다. 값이null이면 단위를 그리지 않습니다. -
value가null이면—를 값의 역할 ·emptyValueColor로 그립니다. - 변동 줄은
change와changeType이 둘 다 있을 때만 그립니다.
상태 전환#
change/changeType변경 시 아이콘과 색이 즉시 갱신됩니다.changeType.positive→ 색 =onSuccessContainer, 화살표 =↑changeType.negative→ 색 =onErrorContainer, 화살표 =↓changeType.neutral→ 색 =onSurfaceVariant, 화살표 =→
애니메이션#
기본 동작 없음. value 슬롯에 다른 컴포넌트 (예: 숫자 ticker) 를 합성해
표현하려면 호출처에서 별도 위젯과 조합합니다.
사용 가이드라인 (Usage Guidelines)#
지표 이름·값·변동량을 카드로 묶으려면 Stat을 사용합니다. 숫자 보간만 필요하면 NumberTicker, 임의의 미디어·본문·액션을 조합하려면 Card, 상태 이력의 여러 구간은 Tracker를 선택합니다. Stat은 전달한 문자열을 표시하며 변동률을 계산하지 않습니다.
✅ Do#
의미 있는 비교 기간을 함께 표시
Stat(
label: '월간 매출',
value: '₩12,450,000',
change: '+11.7% (지난 달 대비)',
changeType: CoreStatChangeType.positive,
)
어느 기간과 비교한 변화율인지 명시하면 수치의 의미를 정확히 전달할 수 있습니다.
❌ Don't#
changeType 과 change 의미가 어긋나지 않게 하세요
// ❌ change 는 양수 표기인데 changeType 은 negative
Stat(
label: '이탈률',
value: '3.2%',
change: '+0.5%',
changeType: CoreStatChangeType.negative,
)
방향이 일치하지 않으면 사용자가 혼란스러워합니다. 이탈률이
증가했다면 보통 부정적이므로 change: '+0.5%' + changeType.negative
로 일치시키거나, 맥락에 따라 change: '-0.5%' + changeType.negative 로
명확히 합니다.
❌ Don't#
너무 많은 Stat 을 한 화면에 나열 금지
// ❌ 15 개 KPI 한 화면에 나열
GridView.count(
crossAxisCount: 5,
children: List.generate(15, (i) => Stat(...)),
)
너무 많은 통계는 인지 과부하를 유발합니다. 한 화면에는 가장 중요한 4 ~ 8 개의 KPI 만 표시하세요.
❌ Don't#
단위 없이 숫자만 표시하지 마세요
// ❌ 단위가 없어 의미를 알 수 없는 숫자
Stat(
label: '방문자',
value: '15234',
)
숫자만 있으면 무엇을 기준으로 한 값인지 알 수 없습니다.
단위(명·원·% 등)를 unit 으로 함께 표시하세요 — value: '15,234', unit: '명'.
접근성 (Accessibility)#
역할#
양 플랫폼 모두 없음. Flutter 위젯 파일에는 Semantics 호출이 하나도 없고, Web 은 루트부터 레이블 · 값 · 변동 행까지 전부 role /
aria-* 없는 <div> · <span> 으로 렌더합니다. 카드 · 지표 · 트렌드라는 구조는 시각 배치로만 존재합니다.
키보드#
없음. Stat 은 값을 그리기만 하고 콜백을 받지 않습니다 — contract 에 onTap 류가 없고, 양 플랫폼 모두 포커스 가능한 자식이 없어 탭 정지가 생기지 않습니다. Web 생성자는
attributes 와 tag 는 노출하지만 이벤트 핸들러 파라미터는 노출하지 않아, 호출자가 키 조작을 직접 붙일 수도 없습니다.
스크린 리더#
- 읽히는 순서 — 레이블 → (아이콘) → 값 → (단위) → (변동 화살표 → 변동 텍스트) → (설명). Flutter 위젯 트리와 Web DOM 순서가 같습니다.
- 빈 값은
—글자로 읽힙니다. "값 없음" 같은 텍스트 대안은 없습니다. - 접근 가능한 이름은 없습니다. 이름을 가질 역할 노드가 없기 때문입니다. 레이블과 값은 서로 무관한 두 텍스트로 읽히고, "이 레이블의 값" 이라는 관계는 발표되지 않습니다.
-
변동 방향은
↑/↓/→글리프와 색으로만 전달됩니다. 글리프는aria-hidden없이 일반 텍스트로 나가고, "상승" / "하락" 같은 텍스트 대안은 없습니다. - 라이브 리전이 없습니다.
value가 갱신돼도 통보되지 않아, 사용자가 다시 읽어야 바뀐 것을 압니다.
포커스 관리#
없음. 오버레이가 아니어서 진입 시 포커스 이동이 없고, 이탈·트랩도 해당하지 않습니다.
알려진 제약#
-
레이블과 값이 시맨틱으로 묶이지 않습니다. Web 은
dl/dt/dd가 아니라 평범한<div>중첩이고, Flutter 에는 둘을 한 노드로 합치는Semantics가 없습니다. 대안 — Web 은 컴포넌트가 자체 role 을 붙이지 않으므로attributes로 넘긴role/aria-label이 그대로 루트에 실리고tag도 바꿀 수 있습니다. Flutter 는Stat을Semantics(...)로 감싸는 것이 유일한 경로입니다. -
변동 방향에 텍스트 대안이 없습니다. 방향이 확실히 읽혀야 하는 자리라면
change문자열 자체에 담으세요 —'+20.1%'의 부호나'20.1% 감소'처럼. -
로딩 자리에 스켈레톤을 넣을 수 없습니다.
label·value·unit·change·description이 전부String이라 위젯을 담지 못하고, 위젯 슬롯은icon하나뿐입니다. 로딩 중에는Stat대신Skeleton트리를 직접 렌더하세요. 빈 값(—)은 값이 없다는 표시이지 로딩 표시가 아닙니다.
전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Stat | Stat |
icon 타입 | Widget? | Component? |
| 변동 화살표 | ↑ / ↓ / → 글리프 |
↑ / ↓ / → 글리프 (동일) |
| 값 · 단위 줄 | Row(crossAxisAlignment: baseline) + 값 Flexible |
flex items-baseline + 값 min-w-0 break-words (같은 배치) |
관련 컴포넌트 (Related Components)#
-
Card:
Stat의 외곽 chrome 을 더 다양하게 표현하고 싶을 때. - Progress: 목표 대비 진행률을 바 형태로 표시할 때.
- NumberTicker: 단순히 애니메이션 숫자만 필요할 때.